diff --git a/.gitignore b/.gitignore index d40ae39..16474dd 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +!md2gost.spec +!md2gost.fast.spec # Installer logs pip-log.txt @@ -130,6 +132,7 @@ venv.bak/ # md2gost diagram PNG cache .md2gost-cache/ +md2gost/vendor/*.jar # LaTeX build artefacts *_latex/ @@ -172,3 +175,9 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +# md2gost runtime (created next to app / cwd on first run) +md2gost.schemes.json +md2gost.include-cache.json +include-cache/ +.md2gost-cache/ diff --git a/ReadMe.docx b/ReadMe.docx new file mode 100644 index 0000000..0bc0b6e Binary files /dev/null and b/ReadMe.docx differ diff --git a/_bpmn_preview.png b/_bpmn_preview.png new file mode 100644 index 0000000..05cf39e Binary files /dev/null and b/_bpmn_preview.png differ diff --git a/_bpmn_sprites.puml b/_bpmn_sprites.puml new file mode 100644 index 0000000..541241d --- /dev/null +++ b/_bpmn_sprites.puml @@ -0,0 +1,3468 @@ +' auto-generated geometric sprites (BPMN 2.0 notation) +sprite $evStart [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000000000000000000000001B70000000 +000002C500000000000000000000000000000005C2000000 +000005C200000000000000000000000000000002C5000000 +000009900000000000000000000000000000000099000000 +00001B50000000000000000000000000000000005B100000 +00002C30000000000000000000000000000000003C200000 +00003C20000000000000000000000000000000002C300000 +00004C20000000000000000000000000000000002C400000 +00005C10000000000000000000000000000000001C500000 +00004C20000000000000000000000000000000002C400000 +00003C20000000000000000000000000000000002C300000 +00002C30000000000000000000000000000000003C200000 +00001B50000000000000000000000000000000005B100000 +000009900000000000000000000000000000000099000000 +000005C200000000000000000000000000000002C5000000 +000002C500000000000000000000000000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartMsg [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000000000000000000000001B70000000 +000002C500000000000000000000000000000005C2000000 +000005C200000008888888888888888800000002C5000000 +000009900000008FFFFFFFFFFFFFFFFF8000000099000000 +00001B500000008FDA88888888888ADF800000005B100000 +00002C300000008F854000000000458F800000003C200000 +00003C200000008F815510000015518F800000002C300000 +00004C200000008F800451000154008F800000002C400000 +00005C100000008F800036202630008F800000001C500000 +00004C200000008F800002646200008F800000002C400000 +00003C200000008F800000252000008F800000002C300000 +00002C300000008F800000000000008F800000003C200000 +00001B500000008FB8888888888888BF800000005B100000 +000009900000008FFFFFFFFFFFFFFFFF8000000099000000 +000005C200000008888888888888888800000002C5000000 +000002C500000000000000000000000000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartTimer [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000000133310000000000001B70000000 +000002C50000000000039BBBBB93000000000005C2000000 +000005C200000000006C8312138C600000000002C5000000 +000009900000000006B400151004B6000000000099000000 +00001B50000000003C40001510004C30000000005B100000 +00002C30000000009800001510000890000000003C200000 +00003C2000000001B3000015100003B1000000002C300000 +00004C2000000003B1000015111001B3000000002C400000 +00005C1000000003B0000015555300B3000000001C500000 +00004C2000000003B1000001111001B3000000002C400000 +00003C2000000001B3000000000003B1000000002C300000 +00002C30000000009800000000000890000000003C200000 +00001B50000000003C40000000004C30000000005B100000 +000009900000000006B400000004B6000000000099000000 +000005C200000000006C8310138C600000000002C5000000 +000002C50000000000039BBBBB93000000000005C2000000 +0000007B1000000000000133310000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartSignal [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000000003000000000000001B70000000 +000002C500000000000000373000000000000005C2000000 +000005C200000000000001646100000000000002C5000000 +000009900000000000000351530000000000000099000000 +00001B50000000000000162026100000000000005B100000 +00002C30000000000000450005400000000000003C200000 +00003C20000000000001620002610000000000002C300000 +00004C20000000000004500000540000000000002C400000 +00005C10000000000026200000262000000000001C500000 +00004C20000000000054000000045000000000002C400000 +00003C20000000000261000000016200000000002C300000 +00002C30000000000540000000004500000000003C200000 +00001B50000000002610000000001620000000005B100000 +000009900000000054111111111114500000000099000000 +000005C200000002666666666666666200000002C5000000 +000002C500000000111111111111111000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartCond [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000004888888888884000000006C20000000 +0000007B100000004BFFFFFFFFFFFB400000001B70000000 +000002C5000000008FB888888888BF8000000005C2000000 +000005C2000000008F80000000008F8000000002C5000000 +00000990000000008F80000000008F800000000099000000 +00001B50000000008F80000000008F80000000005B100000 +00002C30000000008F82555555528F80000000003C200000 +00003C20000000008F80000000008F80000000002C300000 +00004C20000000008F80000000008F80000000002C400000 +00005C10000000008F82555555528F80000000001C500000 +00004C20000000008F80000000008F80000000002C400000 +00003C20000000008F80000000008F80000000002C300000 +00002C30000000008F82555555528F80000000003C200000 +00001B50000000008F80000000008F80000000005B100000 +00000990000000008F80000000008F800000000099000000 +000005C2000000008F80000000008F8000000002C5000000 +000002C5000000008FB888888888BF8000000005C2000000 +0000007B100000004BFFFFFFFFFFFB400000001B70000000 +0000002C6000000004888888888884000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartError [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000100000000000006C20000000 +0000007B1000000000000001510000000000001B70000000 +000002C500000000000000045000000000000005C2000000 +000005C200000000000000162000000000000002C5000000 +000009900000000000000045000000000000000099000000 +00001B50000000000000016300000000000000005B100000 +00002C30000000000000036100000000000000003C200000 +00003C20000000000000164111100000000000002C300000 +00004C20000000000000366666610000000000002C400000 +00005C10000000000000621114400000000000001C500000 +00004C20000000000002644426200000000000002C400000 +00003C20000000000002444555000000000000002C300000 +00002C30000000000000000662000000000000003C200000 +00001B50000000000000001660000000000000005B100000 +000009900000000000000027300000000000000099000000 +000005C200000000000000461000000000000002C5000000 +000002C500000000000000640000000000000005C2000000 +0000007B1000000000000151000000000000001B70000000 +0000002C6000000000000010000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartEsc [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000000000000000000000001B70000000 +000002C500000000000000000000000000000005C2000000 +000005C200000000000000000000000000000002C5000000 +000009900000000000000000000000000000000099000000 +00001B50000000000000000000000000000000005B100000 +00002C30000000000000000100000000000000003C200000 +00003C20000000000000002620000000000000002C300000 +00004C20000000000000026662000000000000002C400000 +00005C10000000000000263636200000000000001C500000 +00004C20000000000002621612620000000000002C400000 +00003C20000000000026201610262000000000002C300000 +00002C30000000000263112621136200000000003C200000 +00001B50000000001666666666666610000000005B100000 +000009900000000001111126211111000000000099000000 +000005C200000000000000161000000000000002C5000000 +000002C500000000000000151000000000000005C2000000 +0000007B1000000000000001000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartMulti [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000000013100000000000001B70000000 +000002C500000000000001565100000000000005C2000000 +000005C200000000000036313630000000000002C5000000 +000009900000000000146200026410000000000099000000 +00001B50000000000255100000155200000000005B100000 +00002C30000000003630000000003630000000003C200000 +00003C20000000016200000000000261000000002C300000 +00004C20000000005300000000000350000000002C400000 +00005C10000000002500000000000520000000001C500000 +00004C20000000001610000000001610000000002C400000 +00003C20000000000530000000003500000000002C300000 +00002C30000000000350000000005300000000003C200000 +00001B50000000000161000000016100000000005B100000 +000009900000000000531111111350000000000099000000 +000005C200000000003666666666300000000002C5000000 +000002C500000000000111111111000000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartPar [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000012345432100000000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +0000000000000027CC95322122359CC72000000000000000 +00000000000016CB520000000000025BC610000000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000003B810000000000000000000000018B3000000000 +00000001AA100000000000000000000000001AA100000000 +00000006C30000000000000000000000000003C600000000 +0000002C6000000000000000000000000000006C20000000 +0000007B1000000000001356531000000000001B70000000 +000002C5000000000015BBA9ABB5100000000005C2000000 +000005C200000000019B5101015B910000000002C5000000 +000009900000000019A200151002A9100000000099000000 +00001B50000000005B20001610002B50000000005B100000 +00002C3000000001B5000016100005B1000000003C200000 +00003C2000000003B1000016100001B3000000002C300000 +00004C2000000005A0111126211110A5000000002C400000 +00005C10000000069156666666665196000000001C500000 +00004C2000000005A0111126211110A5000000002C400000 +00003C2000000003B1000016100001B3000000002C300000 +00002C3000000001B5000016100005B1000000003C200000 +00001B50000000005B20001610002B50000000005B100000 +000009900000000019A200151002A9100000000099000000 +000005C200000000019B5101015B910000000002C5000000 +000002C5000000000015BBA9ABB5100000000005C2000000 +0000007B1000000000001356531000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA100000000000000000000000001AA100000000 +000000003B810000000000000000000000018B3000000000 +0000000004B8100000000000000000000018B40000000000 +00000000004BA300000000000000000003AB400000000000 +000000000003AC6100000000000000016CA3000000000000 +00000000000016CB520000000000025BC610000000000000 +0000000000000027CC95322122359CC72000000000000000 +0000000000000000259BCCCCCCCB95200000000000000000 +000000000000000000012345432100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000002345432000000000000000000000 +000000000000000000116CCCCC6110000000000000000000 +000000000000000111101221221011110000000000000000 +000000000000164100000000000000014610000000000000 +000000000003AC4000000000000000004CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000000881000000000000000000000001880000000000 +000000001110000000000000000000000000111000000000 +000000011000000000000000000000000000001100000000 +000000010000000000000000000000000000000100000000 +000000110000000000000000000000000000000110000000 +000000100000000000000000000000000000000010000000 +000005910000000000000000000000000000000195000000 +000009900000000000000000000000000000000099000000 +00001B50000000000000000000000000000000005B100000 +00002C30000000000000000000000000000000003C200000 +00003C20000000000000000000000000000000002C300000 +00004C20000000000000000000000000000000002C400000 +000026100000000000000000000000000000000016200000 +000001000000000000000000000000000000000001000000 +000001000000000000000000000000000000000001000000 +000001000000000000000000000000000000000001000000 +000001000000000000000000000000000000000001000000 +000001100000000000000000000000000000000011000000 +000000310000000000000000000000000000000130000000 +000002C500000000000000000000000000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA000000000000000000000000000AA100000000 +000000003410000000000000000000000000143000000000 +000000000011000000000000000000000001100000000000 +000000000001100000000000000000000011000000000000 +000000000000112100000000000000012110000000000000 +000000000000018B520000000000025B8100000000000000 +0000000000000027CC95200000259CC72000000000000000 +0000000000000000259B6111116B95200000000000000000 +000000000000000000010000000100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartMsgNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000002345432000000000000000000000 +000000000000000000116CCCCC6110000000000000000000 +000000000000000111101221221011110000000000000000 +000000000000164100000000000000014610000000000000 +000000000003AC4000000000000000004CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000000881000000000000000000000001880000000000 +000000001110000000000000000000000000111000000000 +000000011000000000000000000000000000001100000000 +000000010000000000000000000000000000000100000000 +000000110000000000000000000000000000000110000000 +000000100000000000000000000000000000000010000000 +000005910000000888888888888888880000000195000000 +000009900000008FFFFFFFFFFFFFFFFF8000000099000000 +00001B500000008FDA88888888888ADF800000005B100000 +00002C300000008F854000000000458F800000003C200000 +00003C200000008F815510000015518F800000002C300000 +00004C200000008F800451000154008F800000002C400000 +000026100000008F800036202630008F8000000016200000 +000001000000008F800002646200008F8000000001000000 +000001000000008F800000252000008F8000000001000000 +000001000000008F800000000000008F8000000001000000 +000001000000008FB8888888888888BF8000000001000000 +000001100000008FFFFFFFFFFFFFFFFF8000000011000000 +000000310000000888888888888888880000000130000000 +000002C500000000000000000000000000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA000000000000000000000000000AA100000000 +000000003410000000000000000000000000143000000000 +000000000011000000000000000000000001100000000000 +000000000001100000000000000000000011000000000000 +000000000000112100000000000000012110000000000000 +000000000000018B520000000000025B8100000000000000 +0000000000000027CC95200000259CC72000000000000000 +0000000000000000259B6111116B95200000000000000000 +000000000000000000010000000100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartTimerNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000002345432000000000000000000000 +000000000000000000116CCCCC6110000000000000000000 +000000000000000111101221221011110000000000000000 +000000000000164100000000000000014610000000000000 +000000000003AC4000000000000000004CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000000881000000000000000000000001880000000000 +000000001110000000000000000000000000111000000000 +000000011000000000000000000000000000001100000000 +000000010000000000000000000000000000000100000000 +000000110000000000000133310000000000000110000000 +000000100000000000039BBBBB9300000000000010000000 +0000059100000000006C8312138C60000000000195000000 +000009900000000006B400151004B6000000000099000000 +00001B50000000003C40001510004C30000000005B100000 +00002C30000000009800001510000890000000003C200000 +00003C2000000001B3000015100003B1000000002C300000 +00004C2000000003B1000015111001B3000000002C400000 +0000261000000003B0000015555300B30000000016200000 +0000010000000003B1000001111001B30000000001000000 +0000010000000001B3000000000003B10000000001000000 +000001000000000098000000000008900000000001000000 +00000100000000003C40000000004C300000000001000000 +000001100000000006B400000004B6000000000011000000 +0000003100000000006C8310138C60000000000130000000 +000002C50000000000039BBBBB93000000000005C2000000 +0000007B1000000000000133310000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA000000000000000000000000000AA100000000 +000000003410000000000000000000000000143000000000 +000000000011000000000000000000000001100000000000 +000000000001100000000000000000000011000000000000 +000000000000112100000000000000012110000000000000 +000000000000018B520000000000025B8100000000000000 +0000000000000027CC95200000259CC72000000000000000 +0000000000000000259B6111116B95200000000000000000 +000000000000000000010000000100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evStartSignalNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000002345432000000000000000000000 +000000000000000000116CCCCC6110000000000000000000 +000000000000000111101221221011110000000000000000 +000000000000164100000000000000014610000000000000 +000000000003AC4000000000000000004CA3000000000000 +00000000004BA300000000000000000003AB400000000000 +0000000004B8100000000000000000000018B40000000000 +000000000881000000000000000000000001880000000000 +000000001110000000000000000000000000111000000000 +000000011000000000000000000000000000001100000000 +000000010000000000000000000000000000000100000000 +000000110000000000000003000000000000000110000000 +000000100000000000000037300000000000000010000000 +000005910000000000000164610000000000000195000000 +000009900000000000000351530000000000000099000000 +00001B50000000000000162026100000000000005B100000 +00002C30000000000000450005400000000000003C200000 +00003C20000000000001620002610000000000002C300000 +00004C20000000000004500000540000000000002C400000 +000026100000000000262000002620000000000016200000 +000001000000000000540000000450000000000001000000 +000001000000000002610000000162000000000001000000 +000001000000000005400000000045000000000001000000 +000001000000000026100000000016200000000001000000 +000001100000000054111111111114500000000011000000 +000000310000000266666666666666620000000130000000 +000002C500000000111111111111111000000005C2000000 +0000007B1000000000000000000000000000001B70000000 +0000002C6000000000000000000000000000006C20000000 +00000006C30000000000000000000000000003C600000000 +00000001AA000000000000000000000000000AA100000000 +000000003410000000000000000000000000143000000000 +000000000011000000000000000000000001100000000000 +000000000001100000000000000000000011000000000000 +000000000000112100000000000000012110000000000000 +000000000000018B520000000000025B8100000000000000 +0000000000000027CC95200000259CC72000000000000000 +0000000000000000259B6111116B95200000000000000000 +000000000000000000010000000100000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evInter [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000000000000000000000001B600A7000000 +00000B501B500000000000000000000000005B105B000000 +00002C303C200000000000000000000000002C303C200000 +00003B105B000000000000000000000000000B501B300000 +00005B00780000000000000000000000000008700B500000 +00006A00970000000000000000000000000007900A600000 +000069009600000000000000000000000000069009600000 +00006A00970000000000000000000000000007900A600000 +00005B00780000000000000000000000000008700B500000 +00003B105B000000000000000000000000000B501B300000 +00002C303C200000000000000000000000002C303C200000 +00000B501B500000000000000000000000005B105B000000 +000007A006B1000000000000000000000001B600A7000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchMsg [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000888888888888888880001B600A7000000 +00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 +00002C303C20008FDA88888888888ADF80002C303C200000 +00003B105B00008F854000000000458F80000B501B300000 +00005B007800008F815510000015518F800008700B500000 +00006A009700008F800451000154008F800007900A600000 +000069009600008F800036202630008F8000069009600000 +00006A009700008F800002646200008F800007900A600000 +00005B007800008F800000252000008F800008700B500000 +00003B105B00008F800000000000008F80000B501B300000 +00002C303C20008FB8888888888888BF80002C303C200000 +00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 +000007A006B1000888888888888888880001B600A7000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchTimer [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000013331000000002B500890000000 +000003C202B5000000039BBBBB9300000005B202C3000000 +000007A006B10000006C8312138C60000001B600A7000000 +00000B501B50000006B400151004B60000005B105B000000 +00002C303C2000003C40001510004C3000002C303C200000 +00003B105B000000980000151000089000000B501B300000 +00005B0078000001B3000015100003B1000008700B500000 +00006A0097000003B1000015111001B3000007900A600000 +0000690096000003B0000015555300B30000069009600000 +00006A0097000003B1000001111001B3000007900A600000 +00005B0078000001B3000000000003B1000008700B500000 +00003B105B000000980000000000089000000B501B300000 +00002C303C2000003C40000000004C3000002C303C200000 +00000B501B50000006B400000004B60000005B105B000000 +000007A006B10000006C8310138C60000001B600A7000000 +000003C202B5000000039BBBBB9300000005B202C3000000 +00000098005B20000000013331000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchSignal [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000300000000002B500890000000 +000003C202B5000000000037300000000005B202C3000000 +000007A006B1000000000164610000000001B600A7000000 +00000B501B500000000003515300000000005B105B000000 +00002C303C200000000016202610000000002C303C200000 +00003B105B000000000045000540000000000B501B300000 +00005B00780000000001620002610000000008700B500000 +00006A00970000000004500000540000000007900A600000 +000069009600000000262000002620000000069009600000 +00006A00970000000054000000045000000007900A600000 +00005B00780000000261000000016200000008700B500000 +00003B105B000000054000000000450000000B501B300000 +00002C303C200000261000000000162000002C303C200000 +00000B501B500000541111111111145000005B105B000000 +000007A006B1000266666666666666620001B600A7000000 +000003C202B5000011111111111111100005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchError [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000100000000199103C30000000 +00000098005B20000000000151000000002B500890000000 +000003C202B5000000000004500000000005B202C3000000 +000007A006B1000000000016200000000001B600A7000000 +00000B501B500000000000450000000000005B105B000000 +00002C303C200000000001630000000000002C303C200000 +00003B105B000000000003610000000000000B501B300000 +00005B00780000000000164111100000000008700B500000 +00006A00970000000000366666610000000007900A600000 +000069009600000000006211144000000000069009600000 +00006A00970000000002644426200000000007900A600000 +00005B00780000000002444555000000000008700B500000 +00003B105B000000000000066200000000000B501B300000 +00002C303C200000000000166000000000002C303C200000 +00000B501B500000000000273000000000005B105B000000 +000007A006B1000000000046100000000001B600A7000000 +000003C202B5000000000064000000000005B202C3000000 +00000098005B20000000015100000000002B500890000000 +0000003C3019910000000010000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchEsc [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000000000000000000000001B600A7000000 +00000B501B500000000000000000000000005B105B000000 +00002C303C200000000000000000000000002C303C200000 +00003B105B000000000000010000000000000B501B300000 +00005B00780000000000002620000000000008700B500000 +00006A00970000000000026662000000000007900A600000 +000069009600000000002636362000000000069009600000 +00006A00970000000002621612620000000007900A600000 +00005B00780000000026201610262000000008700B500000 +00003B105B000000026311262113620000000B501B300000 +00002C303C200000166666666666661000002C303C200000 +00000B501B500000011111262111110000005B105B000000 +000007A006B1000000000016100000000001B600A7000000 +000003C202B5000000000015100000000005B202C3000000 +00000098005B20000000000100000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchCond [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910004888888888884000199103C30000000 +00000098005B20004BFFFFFFFFFFFB40002B500890000000 +000003C202B500008FB888888888BF800005B202C3000000 +000007A006B100008F80000000008F800001B600A7000000 +00000B501B5000008F80000000008F8000005B105B000000 +00002C303C2000008F80000000008F8000002C303C200000 +00003B105B0000008F82555555528F8000000B501B300000 +00005B00780000008F80000000008F80000008700B500000 +00006A00970000008F80000000008F80000007900A600000 +00006900960000008F82555555528F800000069009600000 +00006A00970000008F80000000008F80000007900A600000 +00005B00780000008F80000000008F80000008700B500000 +00003B105B0000008F82555555528F8000000B501B300000 +00002C303C2000008F80000000008F8000002C303C200000 +00000B501B5000008F80000000008F8000005B105B000000 +000007A006B100008F80000000008F800001B600A7000000 +000003C202B500008FB888888888BF800005B202C3000000 +00000098005B20004BFFFFFFFFFFFB40002B500890000000 +0000003C3019910004888888888884000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchLink [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000000000000000000000001B600A7000000 +00000B501B500000000000001000000000005B105B000000 +00002C303C200000000000016200000000002C303C200000 +00003B105B000000000000002620000000000B501B300000 +00005B00780000000000000002620000000008700B500000 +00006A00970000011111111111362000000007900A600000 +000069009600001566666666666661000000069009600000 +00006A00970000011111111111362000000007900A600000 +00005B00780000000000000002620000000008700B500000 +00003B105B000000000000002620000000000B501B300000 +00002C303C200000000000016200000000002C303C200000 +00000B501B500000000000001000000000005B105B000000 +000007A006B1000000000000000000000001B600A7000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchComp [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000100000001000000000005B202C3000000 +000007A006B1000541000026200000000001B600A7000000 +00000B501B500005551000266300000000005B105B000000 +00002C303C200005346200252640000000002C303C200000 +00003B105B000005203630250155100000000B501B300000 +00005B00780000052002543500145200000008700B500000 +00006A00970000052000155500003630000007900A600000 +000069009600000520000155000003610000069009600000 +00006A00970000052000155500003630000007900A600000 +00005B00780000052002543500145200000008700B500000 +00003B105B000005203630250155100000000B501B300000 +00002C303C200005346200252640000000002C303C200000 +00000B501B500005551000266300000000005B105B000000 +000007A006B1000541000026200000000001B600A7000000 +000003C202B5000100000001000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchCancel [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000001000000000001000005B202C3000000 +000007A006B1000016300000000036100001B600A7000000 +00000B501B500000036300000003630000005B105B000000 +00002C303C200000003630000036300000002C303C200000 +00003B105B000000000363000363000000000B501B300000 +00005B00780000000000363036300000000008700B500000 +00006A00970000000000036463000000000007900A600000 +000069009600000000000048400000000000069009600000 +00006A00970000000000036463000000000007900A600000 +00005B00780000000000363036300000000008700B500000 +00003B105B000000000363000363000000000B501B300000 +00002C303C200000003630000036300000002C303C200000 +00000B501B500000036300000003630000005B105B000000 +000007A006B1000016300000000036100001B600A7000000 +000003C202B5000001000000000001000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchMulti [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000001310000000002B500890000000 +000003C202B5000000000156510000000005B202C3000000 +000007A006B1000000003631363000000001B600A7000000 +00000B501B500000001462000264100000005B105B000000 +00002C303C200000025510000015520000002C303C200000 +00003B105B000000363000000000363000000B501B300000 +00005B00780000016200000000000261000008700B500000 +00006A00970000005300000000000350000007900A600000 +000069009600000025000000000005200000069009600000 +00006A00970000001610000000001610000007900A600000 +00005B00780000000530000000003500000008700B500000 +00003B105B000000035000000000530000000B501B300000 +00002C303C200000016100000001610000002C303C200000 +00000B501B500000005311111113500000005B105B000000 +000007A006B1000000366666666630000001B600A7000000 +000003C202B5000000011111111100000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evCatchPar [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000135653100000002B500890000000 +000003C202B500000015BBA9ABB510000005B202C3000000 +000007A006B10000019B5101015B91000001B600A7000000 +00000B501B50000019A200151002A91000005B105B000000 +00002C303C2000005B20001610002B5000002C303C200000 +00003B105B000001B5000016100005B100000B501B300000 +00005B0078000003B1000016100001B3000008700B500000 +00006A0097000005A0111126211110A5000007900A600000 +000069009600000691566666666651960000069009600000 +00006A0097000005A0111126211110A5000007900A600000 +00005B0078000003B1000016100001B3000008700B500000 +00003B105B000001B5000016100005B100000B501B300000 +00002C303C2000005B20001610002B5000002C303C200000 +00000B501B50000019A200151002A91000005B105B000000 +000007A006B10000019B5101015B91000001B600A7000000 +000003C202B500000015BBA9ABB510000005B202C3000000 +00000098005B20000000135653100000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evThrowMsg [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000888888888888888880001B600A7000000 +00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 +00002C303C20008FFFFFFFFFFFFFFFFF80002C303C200000 +00003B105B00008FFFFFFFFFFFFFFFFF80000B501B300000 +00005B007800008FFFFFFFFFFFFFFFFF800008700B500000 +00006A009700008FFFFFFFFFFFFFFFFF800007900A600000 +000069009600008FFFFFFFFFFFFFFFFF8000069009600000 +00006A009700008FFFFFFFFFFFFFFFFF800007900A600000 +00005B007800008FFFFFFFFFFFFFFFFF800008700B500000 +00003B105B00008FFFFFFFFFFFFFFFFF80000B501B300000 +00002C303C20008FFFFFFFFFFFFFFFFF80002C303C200000 +00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 +000007A006B1000888888888888888880001B600A7000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evThrowSignal [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000300000000002B500890000000 +000003C202B500000000003F300000000005B202C3000000 +000007A006B10000000001AFA10000000001B600A7000000 +00000B501B500000000006FFF600000000005B105B000000 +00002C303C20000000001CFFFC10000000002C303C200000 +00003B105B00000000006FFFFF60000000000B501B300000 +00005B00780000000001CFFFFFC10000000008700B500000 +00006A00970000000006FFFFFFF60000000007900A600000 +0000690096000000002DFFFFFFFD20000000069009600000 +00006A0097000000007FFFFFFFFF7000000007900A600000 +00005B007800000002DFFFFFFFFFD200000008700B500000 +00003B105B00000009FFFFFFFFFFF90000000B501B300000 +00002C303C2000002FFFFFFFFFFFFF2000002C303C200000 +00000B501B5000009FFFFFFFFFFFFF9000005B105B000000 +000007A006B10002FFFFFFFFFFFFFFF20001B600A7000000 +000003C202B5000011111111111111100005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evThrowEsc [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000000000000000000000001B600A7000000 +00000B501B500000000000000000000000005B105B000000 +00002C303C200000000000000000000000002C303C200000 +00003B105B000000000000010000000000000B501B300000 +00005B00780000000000002620000000000008700B500000 +00006A00970000000000026662000000000007900A600000 +000069009600000000002636362000000000069009600000 +00006A00970000000002621612620000000007900A600000 +00005B00780000000026201610262000000008700B500000 +00003B105B000000026311262113620000000B501B300000 +00002C303C200000166666666666661000002C303C200000 +00000B501B500000011111262111110000005B105B000000 +000007A006B1000000000016100000000001B600A7000000 +000003C202B5000000000015100000000005B202C3000000 +00000098005B20000000000100000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evThrowLink [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000000000000000000000001B600A7000000 +00000B501B500000000000001000000000005B105B000000 +00002C303C200000000000016200000000002C303C200000 +00003B105B000000000000002620000000000B501B300000 +00005B00780000000000000002620000000008700B500000 +00006A00970000011111111111362000000007900A600000 +000069009600001566666666666661000000069009600000 +00006A00970000011111111111362000000007900A600000 +00005B00780000000000000002620000000008700B500000 +00003B105B000000000000002620000000000B501B300000 +00002C303C200000000000016200000000002C303C200000 +00000B501B500000000000001000000000005B105B000000 +000007A006B1000000000000000000000001B600A7000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evThrowComp [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000100000001000000000005B202C3000000 +000007A006B1000541000026200000000001B600A7000000 +00000B501B500005551000266300000000005B105B000000 +00002C303C200005346200252640000000002C303C200000 +00003B105B000005203630250155100000000B501B300000 +00005B00780000052002543500145200000008700B500000 +00006A00970000052000155500003630000007900A600000 +000069009600000520000155000003610000069009600000 +00006A00970000052000155500003630000007900A600000 +00005B00780000052002543500145200000008700B500000 +00003B105B000005203630250155100000000B501B300000 +00002C303C200005346200252640000000002C303C200000 +00000B501B500005551000266300000000005B105B000000 +000007A006B1000541000026200000000001B600A7000000 +000003C202B5000100000001000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evThrowMulti [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000001310000000002B500890000000 +000003C202B5000000000156510000000005B202C3000000 +000007A006B1000000003631363000000001B600A7000000 +00000B501B500000001462000264100000005B105B000000 +00002C303C200000025510000015520000002C303C200000 +00003B105B000000363000000000363000000B501B300000 +00005B00780000016200000000000261000008700B500000 +00006A00970000005300000000000350000007900A600000 +000069009600000025000000000005200000069009600000 +00006A00970000001610000000001610000007900A600000 +00005B00780000000530000000003500000008700B500000 +00003B105B000000035000000000530000000B501B300000 +00002C303C200000016100000001610000002C303C200000 +00000B501B500000005311111113500000005B105B000000 +000007A006B1000000366666666630000001B600A7000000 +000003C202B5000000011111111100000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEnd [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000000000000000016BD83000000 +000005AE9400000000000000000000000000049EA5000000 +000017CC7100000000000000000000000000017CC7100000 +000039EA4000000000000000000000000000004AE9300000 +00004AE830000000000000000000000000000038EA400000 +00015BD720000000000000000000000000000027DB510000 +00016CC610000000000000000000000000000016CC610000 +00017CC610000000000000000000000000000016CC710000 +00017DB610000000000000000000000000000016BD710000 +00017CC610000000000000000000000000000016CC710000 +00016CC610000000000000000000000000000016CC610000 +00015BD720000000000000000000000000000027DB510000 +00004AE830000000000000000000000000000038EA400000 +000039EA4000000000000000000000000000004AE9300000 +000017CC7100000000000000000000000000017CC7100000 +000005AE9400000000000000000000000000049EA5000000 +0000038DB61000000000000000000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndMsg [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000000000000000016BD83000000 +000005AE9400000000000000000000000000049EA5000000 +000017CC7100000888888888888888880000017CC7100000 +000039EA4000008FFFFFFFFFFFFFFFFF8000004AE9300000 +00004AE83000008FFFFFFFFFFFFFFFFF80000038EA400000 +00015BD72000008FFFFFFFFFFFFFFFFF80000027DB510000 +00016CC61000008FFFFFFFFFFFFFFFFF80000016CC610000 +00017CC61000008FFFFFFFFFFFFFFFFF80000016CC710000 +00017DB61000008FFFFFFFFFFFFFFFFF80000016BD710000 +00017CC61000008FFFFFFFFFFFFFFFFF80000016CC710000 +00016CC61000008FFFFFFFFFFFFFFFFF80000016CC610000 +00015BD72000008FFFFFFFFFFFFFFFFF80000027DB510000 +00004AE83000008FFFFFFFFFFFFFFFFF80000038EA400000 +000039EA4000008FFFFFFFFFFFFFFFFF8000004AE9300000 +000017CC7100000888888888888888880000017CC7100000 +000005AE9400000000000000000000000000049EA5000000 +0000038DB61000000000000000000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndError [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000010000000000049EB51000000 +0000038DB61000000000000151000000000016BD83000000 +000005AE9400000000000004500000000000049EA5000000 +000017CC7100000000000016200000000000017CC7100000 +000039EA4000000000000045000000000000004AE9300000 +00004AE830000000000001630000000000000038EA400000 +00015BD720000000000003610000000000000027DB510000 +00016CC610000000000016411110000000000016CC610000 +00017CC610000000000036666661000000000016CC710000 +00017DB610000000000062111440000000000016BD710000 +00017CC610000000000264442620000000000016CC710000 +00016CC610000000000244455500000000000016CC610000 +00015BD720000000000000066200000000000027DB510000 +00004AE830000000000000166000000000000038EA400000 +000039EA4000000000000027300000000000004AE9300000 +000017CC7100000000000046100000000000017CC7100000 +000005AE9400000000000064000000000000049EA5000000 +0000038DB61000000000015100000000000016BD83000000 +0000015BE94000000000001000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndEsc [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000000000000000016BD83000000 +000005AE9400000000000000000000000000049EA5000000 +000017CC7100000000000000000000000000017CC7100000 +000039EA4000000000000000000000000000004AE9300000 +00004AE830000000000000000000000000000038EA400000 +00015BD720000000000000010000000000000027DB510000 +00016CC610000000000000262000000000000016CC610000 +00017CC610000000000002666200000000000016CC710000 +00017DB610000000000026363620000000000016BD710000 +00017CC610000000000262161262000000000016CC710000 +00016CC610000000002620161026200000000016CC610000 +00015BD720000000026311262113620000000027DB510000 +00004AE830000000166666666666661000000038EA400000 +000039EA4000000001111126211111000000004AE9300000 +000017CC7100000000000016100000000000017CC7100000 +000005AE9400000000000015100000000000049EA5000000 +0000038DB61000000000000100000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndCancel [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000000000000000016BD83000000 +000005AE9400000001000000000001000000049EA5000000 +000017CC7100000016300000000036100000017CC7100000 +000039EA4000000003630000000363000000004AE9300000 +00004AE830000000003630000036300000000038EA400000 +00015BD720000000000363000363000000000027DB510000 +00016CC610000000000036303630000000000016CC610000 +00017CC610000000000003646300000000000016CC710000 +00017DB610000000000000484000000000000016BD710000 +00017CC610000000000003646300000000000016CC710000 +00016CC610000000000036303630000000000016CC610000 +00015BD720000000000363000363000000000027DB510000 +00004AE830000000003630000036300000000038EA400000 +000039EA4000000003630000000363000000004AE9300000 +000017CC7100000016300000000036100000017CC7100000 +000005AE9400000001000000000001000000049EA5000000 +0000038DB61000000000000000000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndComp [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000000000000000016BD83000000 +000005AE9400000100000001000000000000049EA5000000 +000017CC7100000541000026200000000000017CC7100000 +000039EA4000000555100026630000000000004AE9300000 +00004AE830000005346200252640000000000038EA400000 +00015BD720000005203630250155100000000027DB510000 +00016CC610000005200254350014520000000016CC610000 +00017CC610000005200015550000363000000016CC710000 +00017DB610000005200001550000036100000016BD710000 +00017CC610000005200015550000363000000016CC710000 +00016CC610000005200254350014520000000016CC610000 +00015BD720000005203630250155100000000027DB510000 +00004AE830000005346200252640000000000038EA400000 +000039EA4000000555100026630000000000004AE9300000 +000017CC7100000541000026200000000000017CC7100000 +000005AE9400000100000001000000000000049EA5000000 +0000038DB61000000000000000000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndSignal [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000300000000000016BD83000000 +000005AE940000000000003F300000000000049EA5000000 +000017CC71000000000001AFA10000000000017CC7100000 +000039EA40000000000006FFF60000000000004AE9300000 +00004AE83000000000001CFFFC10000000000038EA400000 +00015BD72000000000006FFFFF60000000000027DB510000 +00016CC6100000000001CFFFFFC1000000000016CC610000 +00017CC6100000000006FFFFFFF6000000000016CC710000 +00017DB610000000002DFFFFFFFD200000000016BD710000 +00017CC610000000007FFFFFFFFF700000000016CC710000 +00016CC61000000002DFFFFFFFFFD20000000016CC610000 +00015BD72000000009FFFFFFFFFFF90000000027DB510000 +00004AE8300000002FFFFFFFFFFFFF2000000038EA400000 +000039EA400000009FFFFFFFFFFFFF900000004AE9300000 +000017CC71000002FFFFFFFFFFFFFFF20000017CC7100000 +000005AE9400000011111111111111100000049EA5000000 +0000038DB61000000000000000000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evEndMulti [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000001310000000000016BD83000000 +000005AE9400000000000156510000000000049EA5000000 +000017CC7100000000003631363000000000017CC7100000 +000039EA4000000000146200026410000000004AE9300000 +00004AE830000000025510000015520000000038EA400000 +00015BD720000000363000000000363000000027DB510000 +00016CC610000001620000000000026100000016CC610000 +00017CC610000000530000000000035000000016CC710000 +00017DB610000000250000000000052000000016BD710000 +00017CC610000000161000000000161000000016CC710000 +00016CC610000000053000000000350000000016CC610000 +00015BD720000000035000000000530000000027DB510000 +00004AE830000000016100000001610000000038EA400000 +000039EA4000000000531111111350000000004AE9300000 +000017CC7100000000366666666630000000017CC7100000 +000005AE9400000000011111111100000000049EA5000000 +0000038DB61000000000000000000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evTerminate [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000001345677765431000000000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000026AEC841000000000000000148CEA62000000000 +000000016AEB73000000000000000000037BEA6100000000 +000000049DC7300000000000000000000037CD9400000000 +00000038CD830000000000000000000000038DC830000000 +0000015BE94000000000000000000000000049EB51000000 +0000038DB61000000000000100000000000016BD83000000 +000005AE940000000000379A973000000000049EA5000000 +000017CC710000000027CFFFFFC720000000017CC7100000 +000039EA40000000029EFFFFFFFE92000000004AE9300000 +00004AE83000000007EFFFFFFFFFE70000000038EA400000 +00015BD7200000003CFFFFFFFFFFFC3000000027DB510000 +00016CC6100000007FFFFFFFFFFFFF7000000016CC610000 +00017CC6100000009FFFFFFFFFFFFF9000000016CC710000 +00017DB610000001AFFFFFFFFFFFFFA100000016BD710000 +00017CC6100000009FFFFFFFFFFFFF9000000016CC710000 +00016CC6100000007FFFFFFFFFFFFF7000000016CC610000 +00015BD7200000003CFFFFFFFFFFFC3000000027DB510000 +00004AE83000000007EFFFFFFFFFE70000000038EA400000 +000039EA40000000029EFFFFFFFE92000000004AE9300000 +000017CC710000000027CFFFFFC720000000017CC7100000 +000005AE940000000000379A973000000000049EA5000000 +0000038DB61000000000000100000000000016BD83000000 +0000015BE94000000000000000000000000049EB51000000 +00000038CD830000000000000000000000038DC830000000 +000000049DC7300000000000000000000037CD9400000000 +000000016AEB73000000000000000000037BEA6100000000 +0000000026AEC841000000000000000148CEA62000000000 +00000000026ADD9641000000000001469DDA620000000000 +0000000000269CEB974321111123479BEC96200000000000 +00000000000148BDECA876666678ACEDB841000000000000 +0000000000000358ACEEDCCBCCDEECA85300000000000000 +0000000000000013579ABCCDCCBA97531000000000000000 +000000000000000001345677765431000000000000000000 +000000000000000000001111111000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundMsg [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000888888888888888880001B600A7000000 +00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 +00002C303C20008FDA88888888888ADF80002C303C200000 +00003B105B00008F854000000000458F80000B501B300000 +00005B007800008F815510000015518F800008700B500000 +00006A009700008F800451000154008F800007900A600000 +000069009600008F800036202630008F8000069009600000 +00006A009700008F800002646200008F800007900A600000 +00005B007800008F800000252000008F800008700B500000 +00003B105B00008F800000000000008F80000B501B300000 +00002C303C20008FB8888888888888BF80002C303C200000 +00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 +000007A006B1000888888888888888880001B600A7000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundTimer [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000013331000000002B500890000000 +000003C202B5000000039BBBBB9300000005B202C3000000 +000007A006B10000006C8312138C60000001B600A7000000 +00000B501B50000006B400151004B60000005B105B000000 +00002C303C2000003C40001510004C3000002C303C200000 +00003B105B000000980000151000089000000B501B300000 +00005B0078000001B3000015100003B1000008700B500000 +00006A0097000003B1000015111001B3000007900A600000 +0000690096000003B0000015555300B30000069009600000 +00006A0097000003B1000001111001B3000007900A600000 +00005B0078000001B3000000000003B1000008700B500000 +00003B105B000000980000000000089000000B501B300000 +00002C303C2000003C40000000004C3000002C303C200000 +00000B501B50000006B400000004B60000005B105B000000 +000007A006B10000006C8310138C60000001B600A7000000 +000003C202B5000000039BBBBB9300000005B202C3000000 +00000098005B20000000013331000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundError [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000100000000199103C30000000 +00000098005B20000000000151000000002B500890000000 +000003C202B5000000000004500000000005B202C3000000 +000007A006B1000000000016200000000001B600A7000000 +00000B501B500000000000450000000000005B105B000000 +00002C303C200000000001630000000000002C303C200000 +00003B105B000000000003610000000000000B501B300000 +00005B00780000000000164111100000000008700B500000 +00006A00970000000000366666610000000007900A600000 +000069009600000000006211144000000000069009600000 +00006A00970000000002644426200000000007900A600000 +00005B00780000000002444555000000000008700B500000 +00003B105B000000000000066200000000000B501B300000 +00002C303C200000000000166000000000002C303C200000 +00000B501B500000000000273000000000005B105B000000 +000007A006B1000000000046100000000001B600A7000000 +000003C202B5000000000064000000000005B202C3000000 +00000098005B20000000015100000000002B500890000000 +0000003C3019910000000010000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundEsc [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000000000000000000000005B202C3000000 +000007A006B1000000000000000000000001B600A7000000 +00000B501B500000000000000000000000005B105B000000 +00002C303C200000000000000000000000002C303C200000 +00003B105B000000000000010000000000000B501B300000 +00005B00780000000000002620000000000008700B500000 +00006A00970000000000026662000000000007900A600000 +000069009600000000002636362000000000069009600000 +00006A00970000000002621612620000000007900A600000 +00005B00780000000026201610262000000008700B500000 +00003B105B000000026311262113620000000B501B300000 +00002C303C200000166666666666661000002C303C200000 +00000B501B500000011111262111110000005B105B000000 +000007A006B1000000000016100000000001B600A7000000 +000003C202B5000000000015100000000005B202C3000000 +00000098005B20000000000100000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundSignal [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000300000000002B500890000000 +000003C202B5000000000037300000000005B202C3000000 +000007A006B1000000000164610000000001B600A7000000 +00000B501B500000000003515300000000005B105B000000 +00002C303C200000000016202610000000002C303C200000 +00003B105B000000000045000540000000000B501B300000 +00005B00780000000001620002610000000008700B500000 +00006A00970000000004500000540000000007900A600000 +000069009600000000262000002620000000069009600000 +00006A00970000000054000000045000000007900A600000 +00005B00780000000261000000016200000008700B500000 +00003B105B000000054000000000450000000B501B300000 +00002C303C200000261000000000162000002C303C200000 +00000B501B500000541111111111145000005B105B000000 +000007A006B1000266666666666666620001B600A7000000 +000003C202B5000011111111111111100005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundCancel [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000001000000000001000005B202C3000000 +000007A006B1000016300000000036100001B600A7000000 +00000B501B500000036300000003630000005B105B000000 +00002C303C200000003630000036300000002C303C200000 +00003B105B000000000363000363000000000B501B300000 +00005B00780000000000363036300000000008700B500000 +00006A00970000000000036463000000000007900A600000 +000069009600000000000048400000000000069009600000 +00006A00970000000000036463000000000007900A600000 +00005B00780000000000363036300000000008700B500000 +00003B105B000000000363000363000000000B501B300000 +00002C303C200000003630000036300000002C303C200000 +00000B501B500000036300000003630000005B105B000000 +000007A006B1000016300000000036100001B600A7000000 +000003C202B5000001000000000001000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundComp [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910000000000000000000199103C30000000 +00000098005B20000000000000000000002B500890000000 +000003C202B5000100000001000000000005B202C3000000 +000007A006B1000541000026200000000001B600A7000000 +00000B501B500005551000266300000000005B105B000000 +00002C303C200005346200252640000000002C303C200000 +00003B105B000005203630250155100000000B501B300000 +00005B00780000052002543500145200000008700B500000 +00006A00970000052000155500003630000007900A600000 +000069009600000520000155000003610000069009600000 +00006A00970000052000155500003630000007900A600000 +00005B00780000052002543500145200000008700B500000 +00003B105B000005203630250155100000000B501B300000 +00002C303C200005346200252640000000002C303C200000 +00000B501B500005551000266300000000005B105B000000 +000007A006B1000541000026200000000001B600A7000000 +000003C202B5000100000001000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019910000000000000000000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundCond [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +0000000000000039CA53100000135AC93000000000000000 +00000000000028C82000000000000028C820000000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000005C710026BCB87678BCB620017C500000000000 +0000000005C50015BB52000000025BB51005C50000000000 +000000004C50029B510000000000015B92005C4000000000 +00000002B7002B9200000000000000029B2007B200000000 +00000008A102B810000000000000000018B201A800000000 +0000003C3019910004888888888884000199103C30000000 +00000098005B20004BFFFFFFFFFFFB40002B500890000000 +000003C202B500008FB888888888BF800005B202C3000000 +000007A006B100008F80000000008F800001B600A7000000 +00000B501B5000008F80000000008F8000005B105B000000 +00002C303C2000008F80000000008F8000002C303C200000 +00003B105B0000008F82555555528F8000000B501B300000 +00005B00780000008F80000000008F80000008700B500000 +00006A00970000008F80000000008F80000007900A600000 +00006900960000008F82555555528F800000069009600000 +00006A00970000008F80000000008F80000007900A600000 +00005B00780000008F80000000008F80000008700B500000 +00003B105B0000008F82555555528F8000000B501B300000 +00002C303C2000008F80000000008F8000002C303C200000 +00000B501B5000008F80000000008F8000005B105B000000 +000007A006B100008F80000000008F800001B600A7000000 +000003C202B500008FB888888888BF800005B202C3000000 +00000098005B20004BFFFFFFFFFFFB40002B500890000000 +0000003C3019910004888888888884000199103C30000000 +00000008A102B810000000000000000018B201A800000000 +00000002B7002B9200000000000000029B2007B200000000 +000000004C50029B510000000000015B92005C4000000000 +0000000005C50015BB52000000025BB51005C50000000000 +00000000005C710026BCB87678BCB620017C500000000000 +000000000004BA3000135799975310003AB4000000000000 +00000000000028C82000000000000028C820000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BCBBA9ABBCB7300000000000000000 +000000000000000000023566653200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundMsgNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000003566653000000000000000000000 +000000000000000001116BA9AB6111000000000000000000 +000000000000000111000000000001110000000000000000 +000000000000284100000000000000014820000000000000 +000000000004BA2000000799970000002AB4000000000000 +00000000005C71000011187678111000017C500000000000 +0000000005C5001511000000000001151005C50000000000 +000000000950029B300000000000003B9200590000000000 +0000000011002B9200000000000000029B20011000000000 +000000011000B810000000000000000018B0001100000000 +000000010001310000000000000000000131000100000000 +000000110001000000000000000000000001000110000000 +000000100010000000000000000000000000100010000000 +000007800010000888888888888888880000100087000000 +00000B501620008FFFFFFFFFFFFFFFFF800026105B000000 +00002C303C20008FDA88888888888ADF80002C303C200000 +00003B105B00008F854000000000458F80000B501B300000 +00005B007800008F815510000015518F800008700B500000 +00006A009700008F800451000154008F800007900A600000 +000035005300008F800036202630008F8000035005300000 +000001001100008F800002646200008F8000011001000000 +000001001100008F800000252000008F8000011001000000 +000001000100008F800000000000008F8000010001000000 +000001000100008FB8888888888888BF8000010001000000 +000001000540008FFFFFFFFFFFFFFFFF8000450001000000 +0000013006B1000888888888888888880001B60031000000 +000003C202B5000000000000000000000005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019700000000000000000000079103C30000000 +00000008A10111000000000000000000011101A800000000 +00000002B70001100000000000000000110007B200000000 +000000004400001121000000000001211000044000000000 +0000000000100000BB52000000025BB00000100000000000 +000000000001100026BCB11111BCB6200011000000000000 +000000000000111000135111115310001110000000000000 +000000000000019820000000000000289100000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BC6111116CB7300000000000000000 +000000000000000000020001000200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundTimerNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000003566653000000000000000000000 +000000000000000001116BA9AB6111000000000000000000 +000000000000000111000000000001110000000000000000 +000000000000284100000000000000014820000000000000 +000000000004BA2000000799970000002AB4000000000000 +00000000005C71000011187678111000017C500000000000 +0000000005C5001511000000000001151005C50000000000 +000000000950029B300000000000003B9200590000000000 +0000000011002B9200000000000000029B20011000000000 +000000011000B810000000000000000018B0001100000000 +000000010001310000000000000000000131000100000000 +000000110001000000000133310000000001000110000000 +000000100010000000039BBBBB9300000000100010000000 +0000078000100000006C8312138C60000000100087000000 +00000B501620000006B400151004B600000026105B000000 +00002C303C2000003C40001510004C3000002C303C200000 +00003B105B000000980000151000089000000B501B300000 +00005B0078000001B3000015100003B1000008700B500000 +00006A0097000003B1000015111001B3000007900A600000 +0000350053000003B0000015555300B30000035005300000 +0000010011000003B1000001111001B30000011001000000 +0000010011000001B3000000000003B10000011001000000 +000001000100000098000000000008900000010001000000 +00000100010000003C40000000004C300000010001000000 +000001000540000006B400000004B6000000450001000000 +0000013006B10000006C8310138C60000001B60031000000 +000003C202B5000000039BBBBB9300000005B202C3000000 +00000098005B20000000013331000000002B500890000000 +0000003C3019700000000000000000000079103C30000000 +00000008A10111000000000000000000011101A800000000 +00000002B70001100000000000000000110007B200000000 +000000004400001121000000000001211000044000000000 +0000000000100000BB52000000025BB00000100000000000 +000000000001100026BCB11111BCB6200011000000000000 +000000000000111000135111115310001110000000000000 +000000000000019820000000000000289100000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BC6111116CB7300000000000000000 +000000000000000000020001000200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundEscNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000003566653000000000000000000000 +000000000000000001116BA9AB6111000000000000000000 +000000000000000111000000000001110000000000000000 +000000000000284100000000000000014820000000000000 +000000000004BA2000000799970000002AB4000000000000 +00000000005C71000011187678111000017C500000000000 +0000000005C5001511000000000001151005C50000000000 +000000000950029B300000000000003B9200590000000000 +0000000011002B9200000000000000029B20011000000000 +000000011000B810000000000000000018B0001100000000 +000000010001310000000000000000000131000100000000 +000000110001000000000000000000000001000110000000 +000000100010000000000000000000000000100010000000 +000007800010000000000000000000000000100087000000 +00000B50162000000000000000000000000026105B000000 +00002C303C200000000000000000000000002C303C200000 +00003B105B000000000000010000000000000B501B300000 +00005B00780000000000002620000000000008700B500000 +00006A00970000000000026662000000000007900A600000 +000035005300000000002636362000000000035005300000 +000001001100000000026216126200000000011001000000 +000001001100000000262016102620000000011001000000 +000001000100000002631126211362000000010001000000 +000001000100000016666666666666100000010001000000 +000001000540000001111126211111000000450001000000 +0000013006B1000000000016100000000001B60031000000 +000003C202B5000000000015100000000005B202C3000000 +00000098005B20000000000100000000002B500890000000 +0000003C3019700000000000000000000079103C30000000 +00000008A10111000000000000000000011101A800000000 +00000002B70001100000000000000000110007B200000000 +000000004400001121000000000001211000044000000000 +0000000000100000BB52000000025BB00000100000000000 +000000000001100026BCB11111BCB6200011000000000000 +000000000000111000135111115310001110000000000000 +000000000000019820000000000000289100000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BC6111116CB7300000000000000000 +000000000000000000020001000200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundSignalNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000003566653000000000000000000000 +000000000000000001116BA9AB6111000000000000000000 +000000000000000111000000000001110000000000000000 +000000000000284100000000000000014820000000000000 +000000000004BA2000000799970000002AB4000000000000 +00000000005C71000011187678111000017C500000000000 +0000000005C5001511000000000001151005C50000000000 +000000000950029B300000000000003B9200590000000000 +0000000011002B9200000000000000029B20011000000000 +000000011000B810000000000000000018B0001100000000 +000000010001310000000000000000000131000100000000 +000000110001000000000003000000000001000110000000 +000000100010000000000037300000000000100010000000 +000007800010000000000164610000000000100087000000 +00000B50162000000000035153000000000026105B000000 +00002C303C200000000016202610000000002C303C200000 +00003B105B000000000045000540000000000B501B300000 +00005B00780000000001620002610000000008700B500000 +00006A00970000000004500000540000000007900A600000 +000035005300000000262000002620000000035005300000 +000001001100000000540000000450000000011001000000 +000001001100000002610000000162000000011001000000 +000001000100000005400000000045000000010001000000 +000001000100000026100000000016200000010001000000 +000001000540000054111111111114500000450001000000 +0000013006B1000266666666666666620001B60031000000 +000003C202B5000011111111111111100005B202C3000000 +00000098005B20000000000000000000002B500890000000 +0000003C3019700000000000000000000079103C30000000 +00000008A10111000000000000000000011101A800000000 +00000002B70001100000000000000000110007B200000000 +000000004400001121000000000001211000044000000000 +0000000000100000BB52000000025BB00000100000000000 +000000000001100026BCB11111BCB6200011000000000000 +000000000000111000135111115310001110000000000000 +000000000000019820000000000000289100000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BC6111116CB7300000000000000000 +000000000000000000020001000200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $evBoundCondNI [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000003566653000000000000000000000 +000000000000000001116BA9AB6111000000000000000000 +000000000000000111000000000001110000000000000000 +000000000000284100000000000000014820000000000000 +000000000004BA2000000799970000002AB4000000000000 +00000000005C71000011187678111000017C500000000000 +0000000005C5001511000000000001151005C50000000000 +000000000950029B300000000000003B9200590000000000 +0000000011002B9200000000000000029B20011000000000 +000000011000B810000000000000000018B0001100000000 +000000010001310004888888888884000131000100000000 +00000011000100004BFFFFFFFFFFFB400001000110000000 +00000010001000008FB888888888BF800000100010000000 +00000780001000008F80000000008F800000100087000000 +00000B50162000008F80000000008F80000026105B000000 +00002C303C2000008F80000000008F8000002C303C200000 +00003B105B0000008F82555555528F8000000B501B300000 +00005B00780000008F80000000008F80000008700B500000 +00006A00970000008F80000000008F80000007900A600000 +00003500530000008F82555555528F800000035005300000 +00000100110000008F80000000008F800000011001000000 +00000100110000008F80000000008F800000011001000000 +00000100010000008F82555555528F800000010001000000 +00000100010000008F80000000008F800000010001000000 +00000100054000008F80000000008F800000450001000000 +0000013006B100008F80000000008F800001B60031000000 +000003C202B500008FB888888888BF800005B202C3000000 +00000098005B20004BFFFFFFFFFFFB40002B500890000000 +0000003C3019700004888888888884000079103C30000000 +00000008A10111000000000000000000011101A800000000 +00000002B70001100000000000000000110007B200000000 +000000004400001121000000000001211000044000000000 +0000000000100000BB52000000025BB00000100000000000 +000000000001100026BCB11111BCB6200011000000000000 +000000000000111000135111115310001110000000000000 +000000000000019820000000000000289100000000000000 +0000000000000039CA53100000135AC93000000000000000 +000000000000000037BC6111116CB7300000000000000000 +000000000000000000020001000200000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $gwXor [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000001000000000000000000000000 +00000000000000000000004C400000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +000000000000000000004C404C4000000000000000000000 +00000000000000000004C40004C400000000000000000000 +0000000000000000004C4000004C40000000000000000000 +000000000000000004C400000004C4000000000000000000 +00000000000000004C40000000004C400000000000000000 +0000000000000004C4000000000004C40000000000000000 +000000000000004C400000000000004C4000000000000000 +00000000000004C40000000000000004C400000000000000 +0000000000004C4000000000000000004C40000000000000 +000000000004C400000000000000000004C4000000000000 +00000000004C40010000000000000001004C400000000000 +0000000004C4001630000000000000361004C40000000000 +000000004C400003630000000000036300004C4000000000 +00000004C40000003630000000003630000004C400000000 +0000004C4000000003630000000363000000004C40000000 +000004C400000000003630000036300000000004C4000000 +00004C40000000000003630003630000000000004C400000 +0004C4000000000000003631363000000000000004C40000 +004C400000000000000003646300000000000000004C4000 +01C60000000000000000014841000000000000000006C100 +004C400000000000000003646300000000000000004C4000 +0004C4000000000000003631363000000000000004C40000 +00004C40000000000003630003630000000000004C400000 +000004C400000000003630000036300000000004C4000000 +0000004C4000000003630000000363000000004C40000000 +00000004C40000003630000000003630000004C400000000 +000000004C400003630000000000036300004C4000000000 +0000000004C4001630000000000000361004C40000000000 +00000000004C40010000000000000001004C400000000000 +000000000004C400000000000000000004C4000000000000 +0000000000004C4000000000000000004C40000000000000 +00000000000004C40000000000000004C400000000000000 +000000000000004C400000000000004C4000000000000000 +0000000000000004C4000000000004C40000000000000000 +00000000000000004C40000000004C400000000000000000 +000000000000000004C400000004C4000000000000000000 +0000000000000000004C4000004C40000000000000000000 +00000000000000000004C40004C400000000000000000000 +000000000000000000004C404C4000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +00000000000000000000004C400000000000000000000000 +000000000000000000000001000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $gwAnd [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000001000000000000000000000000 +00000000000000000000004C400000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +000000000000000000004C404C4000000000000000000000 +00000000000000000004C40004C400000000000000000000 +0000000000000000004C4000004C40000000000000000000 +000000000000000004C400000004C4000000000000000000 +00000000000000004C40000000004C400000000000000000 +0000000000000004C4000000000004C40000000000000000 +000000000000004C400000000000004C4000000000000000 +00000000000004C40000000000000004C400000000000000 +0000000000004C4000000000000000004C40000000000000 +000000000004C400000000020000000004C4000000000000 +00000000004C40000000002620000000004C400000000000 +0000000004C4000000000026200000000004C40000000000 +000000004C400000000000262000000000004C4000000000 +00000004C40000000000002620000000000004C400000000 +0000004C4000000000000026200000000000004C40000000 +000004C400000000000000262000000000000004C4000000 +00004C40000000000000002620000000000000004C400000 +0004C4000000000000000026200000000000000004C40000 +004C400000000022222222262222222220000000004C4000 +01C60000000002666666666666666666620000000006C100 +004C400000000022222222262222222220000000004C4000 +0004C4000000000000000026200000000000000004C40000 +00004C40000000000000002620000000000000004C400000 +000004C400000000000000262000000000000004C4000000 +0000004C4000000000000026200000000000004C40000000 +00000004C40000000000002620000000000004C400000000 +000000004C400000000000262000000000004C4000000000 +0000000004C4000000000026200000000004C40000000000 +00000000004C40000000002620000000004C400000000000 +000000000004C400000000020000000004C4000000000000 +0000000000004C4000000000000000004C40000000000000 +00000000000004C40000000000000004C400000000000000 +000000000000004C400000000000004C4000000000000000 +0000000000000004C4000000000004C40000000000000000 +00000000000000004C40000000004C400000000000000000 +000000000000000004C400000004C4000000000000000000 +0000000000000000004C4000004C40000000000000000000 +00000000000000000004C40004C400000000000000000000 +000000000000000000004C404C4000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +00000000000000000000004C400000000000000000000000 +000000000000000000000001000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $gwOr [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000001000000000000000000000000 +00000000000000000000004C400000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +000000000000000000004C404C4000000000000000000000 +00000000000000000004C40004C400000000000000000000 +0000000000000000004C4000004C40000000000000000000 +000000000000000004C400000004C4000000000000000000 +00000000000000004C40000000004C400000000000000000 +0000000000000004C4000000000004C40000000000000000 +000000000000004C400000000000004C4000000000000000 +00000000000004C40000000000000004C400000000000000 +0000000000004C4000000000000000004C40000000000000 +000000000004C400000000000000000004C4000000000000 +00000000004C40000000000000000000004C400000000000 +0000000004C4000000000123210000000004C40000000000 +000000004C40000000027BCCCB72000000004C4000000000 +00000004C4000000004BB63336BB4000000004C400000000 +0000004C4000000004C710000017C4000000004C40000000 +000004C4000000002B71000000017B2000000004C4000000 +00004C40000000007B10000000001B70000000004C400000 +0004C40000000001B6000000000006B10000000004C40000 +004C400000000002C3000000000003C200000000004C4000 +01C6000000000003C3000000000003C3000000000006C100 +004C400000000002C3000000000003C200000000004C4000 +0004C40000000001B6000000000006B10000000004C40000 +00004C40000000007B10000000001B70000000004C400000 +000004C4000000002B71000000017B2000000004C4000000 +0000004C4000000004C710000017C4000000004C40000000 +00000004C4000000004BB63336BB4000000004C400000000 +000000004C40000000027BCCCB72000000004C4000000000 +0000000004C4000000000123210000000004C40000000000 +00000000004C40000000000000000000004C400000000000 +000000000004C400000000000000000004C4000000000000 +0000000000004C4000000000000000004C40000000000000 +00000000000004C40000000000000004C400000000000000 +000000000000004C400000000000004C4000000000000000 +0000000000000004C4000000000004C40000000000000000 +00000000000000004C40000000004C400000000000000000 +000000000000000004C400000004C4000000000000000000 +0000000000000000004C4000004C40000000000000000000 +00000000000000000004C40004C400000000000000000000 +000000000000000000004C404C4000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +00000000000000000000004C400000000000000000000000 +000000000000000000000001000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $gwComplex [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000001000000000000000000000000 +00000000000000000000004C400000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +000000000000000000004C404C4000000000000000000000 +00000000000000000004C40004C400000000000000000000 +0000000000000000004C4000004C40000000000000000000 +000000000000000004C400000004C4000000000000000000 +00000000000000004C40000000004C400000000000000000 +0000000000000004C4000000000004C40000000000000000 +000000000000004C400000000000004C4000000000000000 +00000000000004C40000000000000004C400000000000000 +0000000000004C4000000000000000004C40000000000000 +000000000004C400000000000000000004C4000000000000 +00000000004C40000000000000000000004C400000000000 +0000000004C4000000000001000000000004C40000000000 +000000004C400000000000151000000000004C4000000000 +00000004C40000000010001610001000000004C400000000 +0000004C4000000001520016100251000000004C40000000 +000004C400000000002620161026200000000004C4000000 +00004C40000000000002621612620000000000004C400000 +0004C4000000000000002636362000000000000004C40000 +004C400000000000111113666311111000000000004C4000 +01C60000000000015666666866666651000000000006C100 +004C400000000000111113666311111000000000004C4000 +0004C4000000000000002636362000000000000004C40000 +00004C40000000000002621612620000000000004C400000 +000004C400000000002620161026200000000004C4000000 +0000004C4000000001520016100251000000004C40000000 +00000004C40000000010001610001000000004C400000000 +000000004C400000000000151000000000004C4000000000 +0000000004C4000000000001000000000004C40000000000 +00000000004C40000000000000000000004C400000000000 +000000000004C400000000000000000004C4000000000000 +0000000000004C4000000000000000004C40000000000000 +00000000000004C40000000000000004C400000000000000 +000000000000004C400000000000004C4000000000000000 +0000000000000004C4000000000004C40000000000000000 +00000000000000004C40000000004C400000000000000000 +000000000000000004C400000004C4000000000000000000 +0000000000000000004C4000004C40000000000000000000 +00000000000000000004C40004C400000000000000000000 +000000000000000000004C404C4000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +00000000000000000000004C400000000000000000000000 +000000000000000000000001000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $gwEvent [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000001000000000000000000000000 +00000000000000000000004C400000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +000000000000000000004C404C4000000000000000000000 +00000000000000000004C40004C400000000000000000000 +0000000000000000004C4000004C40000000000000000000 +000000000000000004C400000004C4000000000000000000 +00000000000000004C40000000004C400000000000000000 +0000000000000004C4000000000004C40000000000000000 +000000000000004C400000000000004C4000000000000000 +00000000000004C40000000000000004C400000000000000 +0000000000004C4000002467642000004C40000000000000 +000000000004C400014ABA878ABA410004C4000000000000 +00000000004C40002AB510000015BA20004C400000000000 +0000000004C40003B7100013100017B30004C40000000000 +000000004C40002B600001565100006B20004C4000000000 +00000004C40001A70000363136300007A10004C400000000 +0000004C400004B10014620002641001B400004C40000000 +000004C400000A5002551000001552005A000004C4000000 +00004C4000002B1036300000000036301B2000004C400000 +0004C40000004A0162000000000002610A40000004C40000 +004C400000006800530000000000035008600000004C4000 +01C60000000077002500000000000520077000000006C100 +004C400000006800161000000000161008600000004C4000 +0004C40000004A0005300000000035000A40000004C40000 +00004C4000002B1003500000000053001B2000004C400000 +000004C400000A5001610000000161005A000004C4000000 +0000004C400004B10053111111135001B400004C40000000 +00000004C40001A70036666666663007A10004C400000000 +000000004C40002B600111111111006B20004C4000000000 +0000000004C40003B7100000000017B30004C40000000000 +00000000004C40002AB510000015BA20004C400000000000 +000000000004C400014ABA878ABA410004C4000000000000 +0000000000004C4000002467642000004C40000000000000 +00000000000004C40000000000000004C400000000000000 +000000000000004C400000000000004C4000000000000000 +0000000000000004C4000000000004C40000000000000000 +00000000000000004C40000000004C400000000000000000 +000000000000000004C400000004C4000000000000000000 +0000000000000000004C4000004C40000000000000000000 +00000000000000000004C40004C400000000000000000000 +000000000000000000004C404C4000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +00000000000000000000004C400000000000000000000000 +000000000000000000000001000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $gwParEvent [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000001000000000000000000000000 +00000000000000000000004C400000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +000000000000000000004C404C4000000000000000000000 +00000000000000000004C40004C400000000000000000000 +0000000000000000004C4000004C40000000000000000000 +000000000000000004C400000004C4000000000000000000 +00000000000000004C40000000004C400000000000000000 +0000000000000004C4000000000004C40000000000000000 +000000000000004C400000000000004C4000000000000000 +00000000000004C40000000000000004C400000000000000 +0000000000004C4000000000000000004C40000000000000 +000000000004C400000000000000000004C4000000000000 +00000000004C40000000000000000000004C400000000000 +0000000004C4000000001356531000000004C40000000000 +000000004C4000000015BBA9ABB5100000004C4000000000 +00000004C4000000019B5101015B9100000004C400000000 +0000004C4000000019A200151002A9100000004C40000000 +000004C4000000005B20001610002B5000000004C4000000 +00004C4000000001B5000016100005B1000000004C400000 +0004C40000000003B1000016100001B30000000004C40000 +004C400000000005A0111126211110A500000000004C4000 +01C60000000000069156666666665196000000000006C100 +004C400000000005A0111126211110A500000000004C4000 +0004C40000000003B1000016100001B30000000004C40000 +00004C4000000001B5000016100005B1000000004C400000 +000004C4000000005B20001610002B5000000004C4000000 +0000004C4000000019A200151002A9100000004C40000000 +00000004C4000000019B5101015B9100000004C400000000 +000000004C4000000015BBA9ABB5100000004C4000000000 +0000000004C4000000001356531000000004C40000000000 +00000000004C40000000000000000000004C400000000000 +000000000004C400000000000000000004C4000000000000 +0000000000004C4000000000000000004C40000000000000 +00000000000004C40000000000000004C400000000000000 +000000000000004C400000000000004C4000000000000000 +0000000000000004C4000000000004C40000000000000000 +00000000000000004C40000000004C400000000000000000 +000000000000000004C400000004C4000000000000000000 +0000000000000000004C4000004C40000000000000000000 +00000000000000000004C40004C400000000000000000000 +000000000000000000004C404C4000000000000000000000 +0000000000000000000004C6C40000000000000000000000 +00000000000000000000004C400000000000000000000000 +000000000000000000000001000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icUser [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000034300000000000000000000000 +0000000000000000000019CEC91000000000000000000000 +000000000000000000008DFFFD8000000000000000000000 +00000000000000000001BFFFFFB100000000000000000000 +00000000000000000002CFFFFFC200000000000000000000 +00000000000000000001BEFFFEB100000000000000000000 +000000000000000000005DEFED5000000000000000000000 +0000000000000000000004ABA40000000000000000000000 +000000000000000000030000000300000000000000000000 +000000000000000000260000000620000000000000000000 +000000000000000000440000000440000000000000000000 +000000000000000001610000000161000000000000000000 +000000000000000002500000000052000000000000000000 +000000000000000005311111111135000000000000000000 +000000000000000016666666666666100000000000000000 +000000000000000001111111111111000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icService [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000003000000000000000000000000 +000000000000000000000016100000000000000000000000 +000000000000000001200016100021000000000000000000 +000000000000000002620147410262000000000000000000 +000000000000000000265BCCCB5620000000000000000000 +00000000000000000005C73537C500000000000000000000 +0000000000000000001B7400047B10000000000000000000 +0000000000000000114C3000003C41100000000000000000 +0000000000000003667C5000005C76630000000000000000 +0000000000000000114C3000003C41100000000000000000 +0000000000000000001B7400047B10000000000000000000 +00000000000000000005C73537C500000000000000000000 +000000000000000000265BCCCB5620000000000000000000 +000000000000000002620147410262000000000000000000 +000000000000000001200016100021000000000000000000 +000000000000000000000016100000000000000000000000 +000000000000000000000003000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icScript [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000004888888888884000000000000000000 +00000000000000004BFFFFFFFFFFFB400000000000000000 +00000000000000008FB888888888BF800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F82555555528F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F82555555528F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F82555555528F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008F80000000008F800000000000000000 +00000000000000008FB888888888BF800000000000000000 +00000000000000004BFFFFFFFFFFFB400000000000000000 +000000000000000004888888888884000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icManual [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000010000000000000000000000000 +000000000000000000000151003000000000000000000000 +000000000000000000100160016100000000000000000000 +000000000000000001510260026000100000000000000000 +000000000000000003500350035001510000000000000000 +000000000000000005300440044002600000000000000000 +000000000000000016100530053003500000000000000000 +000000000000000035000620052005300000000000000000 +000000000000000053112621262116200000000000000000 +000000000000000166666666666666610000000000000000 +000000000000000011111111111111100000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icRule [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000011111111111111100000000000000000 +000000000000000156666666666666510000000000000000 +000000000000000161111116111111610000000000000000 +000000000000000161000016100001610000000000000000 +000000000000000161000016100001610000000000000000 +000000000000000161000016100001610000000000000000 +000000000000000161111116111111610000000000000000 +000000000000000166666666666666610000000000000000 +000000000000000161111116111111610000000000000000 +000000000000000161000016100001610000000000000000 +000000000000000161000016100001610000000000000000 +000000000000000161000016100001610000000000000000 +000000000000000161111116111111610000000000000000 +000000000000000156666666666666510000000000000000 +000000000000000011111111111111100000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icPlusbox [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000048888888888888400000000000000000 +0000000000000004BFFFFFFFFFFFFFB40000000000000000 +0000000000000008FB88888888888BF80000000000000000 +0000000000000008F8000003000008F80000000000000000 +0000000000000008F8000016100008F80000000000000000 +0000000000000008F8000016100008F80000000000000000 +0000000000000008F8000016100008F80000000000000000 +0000000000000008F8011126211108F80000000000000000 +0000000000000008F8366666666638F80000000000000000 +0000000000000008F8011126211108F80000000000000000 +0000000000000008F8000016100008F80000000000000000 +0000000000000008F8000016100008F80000000000000000 +0000000000000008F8000016100008F80000000000000000 +0000000000000008F8000003000008F80000000000000000 +0000000000000008FB88888888888BF80000000000000000 +0000000000000004BFFFFFFFFFFFFFB40000000000000000 +000000000000000048888888888888400000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icMsg [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000888888888888888880000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FDA88888888888ADF8000000000000000 +000000000000008F854000000000458F8000000000000000 +000000000000008F815510000015518F8000000000000000 +000000000000008F800451000154008F8000000000000000 +000000000000008F800036202630008F8000000000000000 +000000000000008F800002646200008F8000000000000000 +000000000000008F800000252000008F8000000000000000 +000000000000008F800000000000008F8000000000000000 +000000000000008FB8888888888888BF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000000888888888888888880000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icMsgf [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000888888888888888880000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000008FFFFFFFFFFFFFFFFF8000000000000000 +000000000000000888888888888888880000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} + +sprite $icLoop [48x48/16] { +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000002578752000000000000000000000 +00000000000000000039CB989BC930000000000000000000 +000000000000000005C931000139C5000000000000000000 +00000000000000005D61000000016D500000000000000000 +0000000000000003C6000000000006C30000000000000000 +000000000000000991000000000001990000000000000000 +000000000000002C300000000000003C2000000000000000 +000000000000005B100000000000001B5000000000000000 +000000000000007900000000000000097000000000000000 +000000000000008800000000000000088000000000000000 +000000000000007900000000000000097000000000000000 +000000000000005B100000000000011B5000000000000000 +000000000000002C300000000000265C2000000000000000 +000000000000000991000000000264A90000000000000000 +0000000000000003C6000000002636C62000000000000000 +00000000000000005D61000002636D526200000000000000 +000000000000000005C931001659C5002610000000000000 +00000000000000000039CB989BC930000100000000000000 +000000000000000000002578752000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000 +} diff --git a/build-exe-fast.bat b/build-exe-fast.bat new file mode 100644 index 0000000..0b26dd0 --- /dev/null +++ b/build-exe-fast.bat @@ -0,0 +1,85 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul +cd /d "%~dp0" + +set "DO_PAUSE=1" +set "PYI_CLEAN=" +for %%A in (%*) do ( + if /i "%%~A"=="nopause" set "DO_PAUSE=0" + if /i "%%~A"=="clean" set "PYI_CLEAN=--clean" +) + +echo === md2gost: быстрая сборка exe (onefile, шрифты + plantuml.jar) === +echo Каталог: %CD% +echo Spec: md2gost.fast.spec +echo Кэш: build-fast\ (без --clean, пока не передадите clean) +echo. + +where python >nul 2>&1 +if errorlevel 1 ( + echo Python не найден в PATH. Установите Python 3.10+ и отметьте "Add python.exe to PATH". + goto :fail +) + +python -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" +if errorlevel 1 ( + echo Нужен Python 3.10 или новее. + python --version + goto :fail +) + +echo [1/4] Зависимости ... +python -c "import PyInstaller, matplotlib, docx, marko, lxml, pygments, PIL, freetype, latex2mathml, requests, docxcompose" 1>nul 2>nul +if errorlevel 1 ( + echo Ставлю проект + PyInstaller + matplotlib ... + python -m pip install -q -e . "pyinstaller>=6.0" "matplotlib>=3.7" + if errorlevel 1 ( + echo Не удалось поставить зависимости. + goto :fail + ) +) else ( + echo Уже стоят, pip пропускаю. +) + +if not exist "md2gost\Template.docx" ( + echo Нет md2gost\Template.docx — сборка бессмысленна. + goto :fail +) + +echo [2/4] PlantUML jar (обязателен, вшивается в exe) ... +python scripts\fetch_plantuml.py +if not exist "md2gost\vendor\plantuml.jar" ( + echo Нет md2gost\vendor\plantuml.jar — portable exe без jar не собираем. + goto :fail +) + +echo [3/4] PyInstaller onefile, кэш build-fast ... +python -m PyInstaller --noconfirm %PYI_CLEAN% --workpath build-fast --distpath dist md2gost.fast.spec +if errorlevel 1 ( + echo PyInstaller завершился с ошибкой. + goto :fail +) + +if not exist "dist\md2gost.exe" ( + echo dist\md2gost.exe не появился. + goto :fail +) + +echo. +echo [4/4] Готово: +echo %CD%\dist\md2gost.exe +echo. +echo Двойной клик — GUI. Из консоли: md2gost.exe report.md -o report.docx +echo Повторно без clean — быстрее за счёт кэша Analysis. +echo Полный пересбор: build-exe-fast.bat clean +echo. + +if "%DO_PAUSE%"=="1" pause +exit /b 0 + +:fail +echo. +echo Сборка не удалась. +if "%DO_PAUSE%"=="1" pause +exit /b 1 diff --git a/build-exe.bat b/build-exe.bat new file mode 100644 index 0000000..8fc11cf --- /dev/null +++ b/build-exe.bat @@ -0,0 +1,68 @@ +@echo off +setlocal EnableExtensions +chcp 65001 >nul +cd /d "%~dp0" + +echo === md2gost: сборка exe === +echo Каталог: %CD% +echo. + +where python >nul 2>&1 +if errorlevel 1 ( + echo Python не найден в PATH. Установите Python 3.10+ и отметьте "Add python.exe to PATH". + goto :fail +) + +python -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" +if errorlevel 1 ( + echo Нужен Python 3.10 или новее. + python --version + goto :fail +) + +echo [1/4] Зависимости проекта + PyInstaller + matplotlib ... +python -m pip install -q -e . "pyinstaller>=6.0" "matplotlib>=3.7" +if errorlevel 1 ( + echo Не удалось поставить зависимости. + goto :fail +) + +if not exist "md2gost\Template.docx" ( + echo Нет md2gost\Template.docx — сборка бессмысленна. + goto :fail +) + +echo [2/4] PlantUML jar (вшивается в exe, если скачается^) ... +python scripts\fetch_plantuml.py +if not exist "md2gost\vendor\plantuml.jar" ( + echo Предупреждение: plantuml.jar нет — в exe диаграммы пойдут через kroki.io. +) + +echo [3/4] PyInstaller (один файл, без консоли; CLI подцепит консоль сам^) ... +python -m PyInstaller --noconfirm --clean md2gost.spec +if errorlevel 1 ( + echo PyInstaller завершился с ошибкой. + goto :fail +) + +if not exist "dist\md2gost.exe" ( + echo dist\md2gost.exe не появился. + goto :fail +) + +echo. +echo [4/4] Готово: +echo %CD%\dist\md2gost.exe +echo. +echo Двойной клик — GUI. Из консоли: md2gost.exe report.md -o report.docx +echo. + +if /i "%~1"=="nopause" goto :eof +pause +exit /b 0 + +:fail +echo. +echo Сборка не удалась. +if /i not "%~1"=="nopause" pause +exit /b 1 diff --git a/md2gost.fast.spec b/md2gost.fast.spec new file mode 100644 index 0000000..bb63df0 --- /dev/null +++ b/md2gost.fast.spec @@ -0,0 +1,162 @@ +# -*- mode: python ; coding: utf-8 -*- +"""Faster onefile freeze: same portable exe (fonts + plantuml.jar + onefile). + +Keeps matplotlib (system font lookup) and bundles plantuml.jar. +Speeds Analysis by pinning the Agg backend, skipping duplicate pygments +collection (the stock hook already pulls all lexers), and not probing Qt/Gtk. +""" +import os +from pathlib import Path + +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_dynamic_libs, + collect_submodules, +) + +# Isolated matplotlib hook subprocesses inherit this and skip GUI backend probes. +os.environ["MPLBACKEND"] = "Agg" + +ROOT = Path(SPECPATH).resolve() + +datas = [ + (str(ROOT / "md2gost" / "Template.docx"), "md2gost"), + (str(ROOT / "md2gost" / "mml2omml"), "md2gost"), + (str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"), + (str(ROOT / "prompts"), "prompts"), +] +_plantuml = ROOT / "md2gost" / "vendor" / "plantuml.jar" +if not _plantuml.is_file(): + raise SystemExit( + "md2gost.fast.spec: нет md2gost/vendor/plantuml.jar — " + "сначала scripts/fetch_plantuml.py (jar обязателен в portable exe)" + ) +datas.append((str(_plantuml), "md2gost/vendor")) + +binaries = [] +hiddenimports = collect_submodules("md2gost") + +datas += collect_data_files("certifi") +datas += collect_data_files("lxml") +datas += collect_data_files("latex2mathml") +datas += collect_data_files("docx") +# Explicit mpl-data so DejaVu + matplotlib font cache files are always in the exe. +# hook-matplotlib.py also collects this; PyInstaller dedupes by dest path. +datas += collect_data_files("matplotlib", includes=["mpl-data/**"]) + +for pkg in ("lxml", "freetype"): + try: + binaries += collect_dynamic_libs(pkg) + except Exception: + pass + +hiddenimports += [ + "lxml", + "lxml.etree", + "lxml._elementpath", + "PIL", + "PIL.Image", + "PIL.PngImagePlugin", + "pygments", + "pygments.lexers", + "pygments.formatters", + "freetype", + "docx", + "docxcompose", + "docxcompose.composer", + "marko", + "marko.ext.gfm", + "latex2mathml", + "latex2mathml.converter", + "requests", + "certifi", + "matplotlib", + "matplotlib.font_manager", + "matplotlib.ft2font", + "tkinter", + "tkinter.filedialog", + "tkinter.messagebox", + "tkinter.ttk", +] +# Do NOT collect_submodules("pygments.lexers") here — hook-pygments.py already +# collects all lexers/formatters/styles once during Analysis. +try: + hiddenimports += collect_submodules("tkinterdnd2") +except Exception: + pass + +excludes = [ + "pytest", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "IPython", + "jupyter", + "notebook", + "zmq", + "scipy", + "pandas", + "tornado", + "numpy.tests", + "matplotlib.tests", + "matplotlib.testing", + "matplotlib.backends.qt_compat", + "matplotlib.backends.backend_qtagg", + "matplotlib.backends.backend_qtcairo", + "matplotlib.backends.backend_qt5agg", + "matplotlib.backends.backend_qt5cairo", + "matplotlib.backends.backend_qt5", + "matplotlib.backends.backend_qt", + "matplotlib.backends.backend_gtk3", + "matplotlib.backends.backend_gtk3agg", + "matplotlib.backends.backend_gtk3cairo", + "matplotlib.backends.backend_gtk4", + "matplotlib.backends.backend_gtk4agg", + "matplotlib.backends.backend_gtk4cairo", + "matplotlib.backends.backend_macosx", + "matplotlib.backends.backend_tkagg", + "matplotlib.backends.backend_tkcairo", + "matplotlib.backends.backend_wx", + "matplotlib.backends.backend_wxagg", + "matplotlib.backends.backend_wxcairo", + "matplotlib.backends.backend_nbagg", + "matplotlib.backends.backend_webagg", + "PIL.ImageQt", +] + +a = Analysis( + [str(ROOT / "scripts" / "md2gost_exe.py")], + pathex=[str(ROOT)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={ + "matplotlib": {"backends": "Agg"}, + }, + runtime_hooks=[str(ROOT / "scripts" / "pyi_rth_mplbackend.py")], + excludes=excludes, + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="md2gost", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/md2gost.spec b/md2gost.spec new file mode 100644 index 0000000..a0f0d27 --- /dev/null +++ b/md2gost.spec @@ -0,0 +1,123 @@ +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path + +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_dynamic_libs, + collect_submodules, +) + +ROOT = Path(SPECPATH).resolve() + +datas = [ + (str(ROOT / "md2gost" / "Template.docx"), "md2gost"), + (str(ROOT / "md2gost" / "mml2omml"), "md2gost"), + (str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"), + (str(ROOT / "prompts"), "prompts"), +] +_plantuml = ROOT / "md2gost" / "vendor" / "plantuml.jar" +if _plantuml.is_file(): + datas.append((str(_plantuml), "md2gost/vendor")) +binaries = [] +hiddenimports = collect_submodules("md2gost") + +datas += collect_data_files("certifi") +datas += collect_data_files("lxml") +datas += collect_data_files("latex2mathml") +datas += collect_data_files("docx") +datas += collect_data_files("matplotlib", includes=["mpl-data/**"]) + +for pkg in ("lxml", "freetype"): + try: + binaries += collect_dynamic_libs(pkg) + except Exception: + pass + +hiddenimports += [ + "lxml", + "lxml.etree", + "lxml._elementpath", + "PIL", + "PIL.Image", + "PIL.PngImagePlugin", + "pygments", + "pygments.lexers", + "pygments.formatters", + "freetype", + "docx", + "docxcompose", + "docxcompose.composer", + "marko", + "marko.ext.gfm", + "latex2mathml", + "latex2mathml.converter", + "requests", + "certifi", + "matplotlib", + "matplotlib.font_manager", + "matplotlib.ft2font", + "tkinter", + "tkinter.filedialog", + "tkinter.messagebox", + "tkinter.ttk", +] +hiddenimports += collect_submodules("pygments.lexers") +hiddenimports += collect_submodules("marko") +try: + hiddenimports += collect_submodules("tkinterdnd2") +except Exception: + pass + +excludes = [ + "pytest", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "IPython", + "jupyter", + "notebook", + "zmq", + "matplotlib.backends.qt_compat", + "matplotlib.backends.backend_qtagg", + "matplotlib.backends.backend_qt5agg", + "matplotlib.backends.backend_qt5", + "matplotlib.backends.backend_qt", + "matplotlib.backends.backend_gtk3", + "matplotlib.backends.backend_gtk4", + "matplotlib.backends.backend_macosx", +] + +a = Analysis( + [str(ROOT / "scripts" / "md2gost_exe.py")], + pathex=[str(ROOT)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="md2gost", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/md2gost/README.docx b/md2gost/README.docx new file mode 100644 index 0000000..31b298a Binary files /dev/null and b/md2gost/README.docx differ diff --git a/md2gost/README.md b/md2gost/README.md index e68d764..0a65e18 100644 --- a/md2gost/README.md +++ b/md2gost/README.md @@ -10,6 +10,32 @@ poetry install pip install -e . ``` +## GUI + +Без файла или с `--gui` открывается окно: перетащите `.md`, выберите параметры, нажмите «Конвертировать». CLI при этом тот же (`python -m md2gost файл.md …`). + +```bash +python -m md2gost +python -m md2gost --gui +python -m md2gost --gui report.md --type PIS_custom +md2gost-gui +``` + +На Windows файл можно бросить из Проводника в верхнюю область окна. Клик по области — выбор через диалог. Все флаги CLI есть в форме (тип, нумерация, TOC, тире, `---` → разрыв страницы, титул/задание, диаграммы, проверка ТЗ). + +Вкладки **Инструкция** и **Промпт для ИИ** — справка и копирование системного промпта (МИРЭА / ПИС) в буфер. + +### Сборка exe (Windows) + +Двойной клик по `build-exe.bat` в корне репозитория (нужен Python 3.10+ в PATH). Результат: `dist\md2gost.exe`. + +```bat +build-exe.bat +build-exe.bat nopause +``` + +Двойной клик по exe — GUI. CLI: `md2gost.exe report.md -o report.docx --type coursework`. + ## CLI ```bash @@ -31,7 +57,7 @@ python -m md2gost report.md -o report.docx --type PIS_custom --check # Титул: «Отчёт по практическим работам …» — отдельный DOCX: python -m md2gost report.md -o report.docx --type PIS_custom --title title.docx # Курсовая АПИД (источники 7–20, проверка пунктов 2.1–2.4): -python -m md2gost report.md -o report.docx --type APID_coursework --check --no-emdash-to-hyphen --title title.docx --assignment assignment.docx +python -m md2gost report.md -o report.docx --type APID_coursework --check --title title.docx --assignment assignment.docx ``` Пример: [`examples/pis_custom.md`](../examples/pis_custom.md). @@ -57,8 +83,12 @@ python -m md2gost report.md -o report.docx --toc manual ### Тире (`--emdash-to-hyphen` / `--no-emdash-to-hyphen`) -По умолчанию типографское «—» заменяется на «-» (в тексте и подписях). -Оставить длинное тире по ГОСТ: `--no-emdash-to-hyphen`. +По умолчанию типографское «—» **сохраняется** (как в методичке: тире с пробелами, дефис в диапазонах). +Заменить «—» на «-»: `--emdash-to-hyphen`. + +### Разрыв страницы (`---` / `--hr-pagebreak`) + +Строка `---` (также `***`, `___`) на отдельной строке по умолчанию **игнорируется**. Разрыв страницы: `--hr-pagebreak` или галочка в GUI. ## Синтаксис (кратко) @@ -70,9 +100,10 @@ python -m md2gost report.md -o report.docx --toc manual | Таблица | `%id Подпись` перед таблицей + `@Таблица:id` | | Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) | | Листинг | `%id Подпись` перед code fence | -| Диаграмма UML/BPMN/C4 | `%id Подпись` + ````uml` / ````bpmn` / ````c4` → PNG (Рисунок); `+listing` — ещё и Листинг | +| Диаграмма UML / Mermaid / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````bpmn` / ````mermaid` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json`. IDEF0 нет | | Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) | | Источник | `[1]` в тексте; `[1]: …` в списке | +| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) | ### Таблицы со склеиванием @@ -98,22 +129,44 @@ python -m md2gost report.md -o report.docx --toc manual Разные шапки «первый раз / продолжение» есть в LaTeX (`longtable`), не в Office. Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается -mid-page «Продолжение…» (как было на 2.4). Поэтому по умолчанию таблицу **не режем**. +mid-page «Продолжение…» (как было на 2.4). По умолчанию режим **`word`**: после save +Word COM режет по реальной пагинации. Без Word — укажите `off` или поставьте Word + pywin32. | Режим | Поведение | |--------|-----------| -| **`off`** / **`soft`** (по умолчанию) | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно | +| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`) | +| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно | | **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) | | **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) | ```bash +python -m md2gost report.md -o report.docx --table-continuation word python -m md2gost report.md -o report.docx --table-continuation off python -m md2gost report.md -o report.docx --table-continuation caption ``` +### Продолжение листинга (`--listing-continuation`) + +Те же режимы, что у таблиц. По умолчанию **`word`**. + +| Режим | Поведение | +|--------|-----------| +| **`word`** (по умолчанию) | После save Word COM + «Продолжение Листинга N» (Windows + Word + pywin32) | +| **`off`** / **`soft`** | Один блок кода; пагинацию делает Word. «Продолжение…» — вручную в markdown, если нужно | +| **`legacy`** | Режем по оценке высоты + «Продолжение Листинга N» с `page_break_before` | +| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение Листинга N» | + +```bash +python -m md2gost report.md -o report.docx --listing-continuation word +python -m md2gost report.md -o report.docx --listing-continuation off +python -m md2gost report.md -o report.docx --listing-continuation caption +``` + ### Диаграммы -```markdown +В отчёте пишите так (пример в 4 обратных кавычках, чтобы вложенный ` ```uml ` не ломал разметку): + +````markdown %usecase1 Диаграмма прецедентов +listing ```uml @@ -122,19 +175,86 @@ actor Student Student --> (Login) @enduml ``` +```` + +Широкая схема на альбомной странице — флаг `+landscape` в той же строке `%`: + +%arch1 Архитектура +landscape + +```uml-c4 +Person(user, "Студент") +System(app, "Портал") ``` -Рендер (по приоритету): +````markdown +%arch1 Архитектура +landscape -1. `PLANTUML_JAR` / `--plantuml-jar` + Java → `plantuml.jar` -2. `KROKI_URL` / `--kroki-url` (по умолчанию `http://localhost:8000`) -3. remote `https://kroki.io` при `--diagram-fallback remote` (по умолчанию; предупреждение в лог) +```uml-c4 +Person(user, "Студент") +System(app, "Портал") +``` +```` -Кэш PNG: `{каталог_md}/.md2gost-cache/`. +Схемы (`c4`, `usecase`, свои): при первом запуске рядом с приложением создаётся `md2gost.schemes.json`. В markdown — оградка `uml-` или короткое ``: +````markdown +%arch C4 + +```uml-c4 +Person(user, "Студент") +System(app, "Портал") +Rel(user, app, "логин") +``` +```` + +URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **BPMN 2.0** — оградка ````bpmn` / ````uml-bpmn` (макросы `Start`, `UserTask`, `XOR`, `Flow`, `Pool`…; библиотека `diagrams/BPMN.puml`). **Mermaid** — ````mermaid` / ````mmd` через тот же Kroki (свой `--kroki-url` или kroki.io); jar не используется. **IDEF0** конвертер не рисует — вставляйте готовый PNG. + +```bpmn +StartMessage(s, "заявка") +UserTask(t, "Проверить") +XOR(gw, "ок?") +End(e_ok) +End(e_no) +Flow(s, t) +Flow(t, gw) +CondFlow(gw, e_ok, "да") +DefaultFlow(gw, e_no) +``` +````markdown +%bpmn1 Процесс заявки + +```bpmn +StartMessage(s, "заявка") +UserTask(t, "Проверить") +XOR(gw, "ок?") +End(e_ok) +End(e_no) +Flow(s, t) +Flow(t, gw) +CondFlow(gw, e_ok, "да") +DefaultFlow(gw, e_no) +``` +```` + +Обычному пользователю jar/Kroki указывать не нужно. Порядок для UML: + +1. Вшитый / скачанный `plantuml.jar` + Java (exe кладёт jar внутрь; иначе качаем в `%LOCALAPPDATA%\md2gost\`) +2. Локальный Kroki (`KROKI_URL` / `--kroki-url`, по умолчанию `http://localhost:8000`) +3. Интернет `https://kroki.io` при `--diagram-fallback remote` (по умолчанию) + +Mermaid всегда идёт через Kroki (шаги 2–3). + +Формат в Word: `--diagram-format png` (по умолчанию; PlantUML рендерится с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр). + +Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная. + +Свой jar — только если нужен другой файл: `--plantuml-jar` или поле на вкладке «Диаграммы». + +Кэш: `{каталог_md}/.md2gost-cache/` (`*.png`, при svg ещё `*.svg`). ```bash -python -m md2gost report.md -o report.docx --plantuml-jar C:\tools\plantuml.jar +python scripts/fetch_plantuml.py python -m md2gost report.md -o report.docx --diagram-fallback local +python -m md2gost report.md -o report.docx --diagram-format svg ``` Подробности и ИИ-промпт: [`prompts/`](../prompts/). @@ -144,3 +264,5 @@ PDF через LaTeX (XeLaTeX, шаблон МИРЭА): [`md2latex/README.md`]( ## Проверки `--check` печатает замечания по структуре, «рис.», ссылкам во введении, числу/возрасту источников, приложениям и т.д. `--strict` завершает процесс с кодом 1 при ошибках. + +`--check-pages` — пост-проверка полупустых страниц **через Microsoft Word** (Windows + Word + `pip install pywin32`). Все находки помечены как эвристика и **могут быть ложными**; не влияют на `--strict`. Можно вызвать для готового файла: `python -m md2gost report.docx --check-pages`. Макрос Word: [`scripts/check_page_fill.bas`](../scripts/check_page_fill.bas). diff --git a/md2gost/__init__.py b/md2gost/__init__.py index a7f8ba6..527c287 100644 --- a/md2gost/__init__.py +++ b/md2gost/__init__.py @@ -1,8 +1,17 @@ """md2gost — Markdown → DOCX (MIREA TZ / GOST).""" import os +import sys def package_dir() -> str: """Directory of the md2gost package (Template.docx, mml2omml, etc.).""" + if getattr(sys, "frozen", False): + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + bundled = os.path.join(meipass, "md2gost") + if os.path.isfile(os.path.join(bundled, "Template.docx")): + return bundled + if os.path.isfile(os.path.join(meipass, "Template.docx")): + return meipass return os.path.dirname(os.path.abspath(__file__)) diff --git a/md2gost/__main__.py b/md2gost/__main__.py index 7db591b..bb1d358 100644 --- a/md2gost/__main__.py +++ b/md2gost/__main__.py @@ -1,41 +1,44 @@ #!/usr/bin/env python from argparse import ArgumentParser, BooleanOptionalAction -import os -import os.path import sys -from getpass import getuser -from docx import Document - -from .converter import Converter +from .pipeline import ConvertRequest, convert, should_launch_gui from .profiles import ( + DEFAULT_HEADING_NUMBERING, + DEFAULT_TABLE_CONTINUATION, + DEFAULT_LISTING_CONTINUATION, + DEFAULT_TOC_MODE, DOC_TYPES, HEADING_NUMBERING_MODES, - DEFAULT_HEADING_NUMBERING, - TOC_MODES, - DEFAULT_TOC_MODE, TABLE_CONTINUATION_MODES, - DEFAULT_TABLE_CONTINUATION, - get_profile, + LISTING_CONTINUATION_MODES, + TOC_MODES, ) -from .checker import check_markdown, format_report -def main(): +def build_parser() -> ArgumentParser: parser = ArgumentParser( prog="md2gost", description=( "Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. " "Типы: coursework/practice/vkr, APID_coursework, PIS_custom. " + "Без файла или с --gui открывается окно. " "FODT: python -m md2fodt …" ), ) - parser.add_argument("filename", help="Путь до исходного markdown файла") + parser.add_argument( + "filename", nargs="?", + help="Путь до .md (или .docx с --check-pages). Без файла открывается GUI", + ) + parser.add_argument( + "--gui", action="store_true", + help="Открыть графический интерфейс (можно сразу передать .md)", + ) parser.add_argument("-o", "--output", help="Путь до сгенерированного .docx") parser.add_argument("-t", "--template", help="Путь до шаблона .docx") parser.add_argument( - "--type", dest="doc_type", choices=DOC_TYPES, default="coursework", - help="Тип: coursework | practice | vkr | PIS_custom | APID_coursework", + "--type", dest="doc_type", choices=DOC_TYPES, default="practice", + help="Тип: practice | coursework | vkr | PIS_custom | APID_coursework (по умолчанию practice)", ) parser.add_argument( "--heading-numbering", @@ -64,19 +67,53 @@ def main(): default=DEFAULT_TABLE_CONTINUATION, help=( "Таблицы длиннее страницы: " - "off/soft — одна таблица, пагинация Word, без авто«Продолжение» (по умолчанию); " + "word — после сохранения Word COM режет по реальной пагинации + «Продолжение…» " + "(по умолчанию; нужны Windows, Word, pywin32); " + "off/soft — одна таблица, пагинация Word, без авто«Продолжение»; " "legacy/caption — режем по оценке высоты и вставляем «Продолжение…» " "(оценка ≠ Word, возможны артефакты). " f"По умолчанию: {DEFAULT_TABLE_CONTINUATION}." ), ) + parser.add_argument( + "--listing-continuation", + choices=LISTING_CONTINUATION_MODES, + default=DEFAULT_LISTING_CONTINUATION, + help=( + "Листинги длиннее страницы: " + "word — после сохранения Word COM + «Продолжение Листинга…» " + "(по умолчанию; нужны Windows, Word, pywin32); " + "off/soft — один блок, пагинация Word, без авто«Продолжение»; " + "legacy/caption — режем по оценке высоты и вставляем «Продолжение Листинга…» " + "(оценка ≠ Word, возможны артефакты). " + f"По умолчанию: {DEFAULT_LISTING_CONTINUATION}." + ), + ) + parser.add_argument( + "--table-repeat-header", + action=BooleanOptionalAction, + default=False, + help=( + "При --table-continuation word: повторять шапку таблицы на каждом фрагменте " + "после разрыва. По умолчанию выкл." + ), + ) parser.add_argument( "--emdash-to-hyphen", action=BooleanOptionalAction, - default=True, + default=False, help=( "Автозамена типографского тире «—» на дефис «-» в тексте и подписях. " - "Включено по умолчанию; отключить: --no-emdash-to-hyphen." + "По умолчанию выключено (тире «—» по методичке); включить: --emdash-to-hyphen." + ), + ) + parser.add_argument( + "--hr-pagebreak", + action=BooleanOptionalAction, + default=False, + help=( + "Строка «---» / «***» / «___» — разрыв страницы Word. " + "По умолчанию такие строки игнорируются; включить: --hr-pagebreak." ), ) parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом)") @@ -85,6 +122,15 @@ def main(): action="store_true") parser.add_argument("--check-only", help="Только проверка, без генерации документа", action="store_true") + parser.add_argument( + "--check-pages", + help=( + "После конвертации (или для готового .docx) проверить полупустые страницы " + "через Microsoft Word. Эвристика — возможны ложные срабатывания. " + "Нужны Windows, Word и pywin32." + ), + action="store_true", + ) parser.add_argument("--strict", help="Код выхода 1 при ошибках проверки", action="store_true") parser.add_argument("--syntax-highlighting", help="Подсветка синтаксиса в листингах", @@ -103,108 +149,104 @@ def main(): default="remote", help="Если локальный рендер UML недоступен: remote (kroki.io), local (ошибка), off", ) + parser.add_argument( + "--diagram-format", + choices=["png", "svg"], + default="png", + help="Формат схем в Word: png (по умолчанию) или svg (вектор + PNG-запасной, Word 2016+)", + ) + parser.add_argument( + "--diagram-scale", + type=float, + default=2.0, + help="Масштаб рендера PlantUML PNG (качество); размер на странице как при 1. По умолчанию 2", + ) + parser.add_argument( + "--schemes", + dest="schemes_path", + help="Путь к md2gost.schemes.json (иначе рядом с приложением / с .md)", + ) parser.add_argument("--debug", help="Добавляет отладочные данные в документ", action="store_true") + return parser - args = parser.parse_args() - filename, output, template, debug = \ - args.filename, args.output, args.template, args.debug - if args.syntax_highlighting: - os.environ["SYNTAX_HIGHLIGHTING"] = "1" - from .diagram_renderer import configure_diagrams - configure_diagrams( - plantuml_jar=args.plantuml_jar, - kroki_url=args.kroki_url, - fallback=args.diagram_fallback, - ) - - if not filename.endswith(".md"): - print("Error: filename must have md format") - exit(1) - - os.environ["WORKING_DIR"] = os.path.dirname(os.path.abspath(filename)) or "." - - with open(filename, encoding="utf-8") as f: - md_text = f.read() - - if args.check or args.check_only: - issues = check_markdown(md_text, args.doc_type) - print(format_report(issues)) - errors = [i for i in issues if i.severity == "error"] - if args.strict and errors: - sys.exit(1) - if args.check_only: - sys.exit(0 if not errors else (1 if args.strict else 0)) - - if not output: - output = os.path.basename(filename).replace(".md", ".docx") - elif not output.endswith(".docx"): - print("Error: output file must have docx format") - exit(1) - - if not template: - from . import package_dir - template = os.path.join(package_dir(), "Template.docx") - - converter = Converter( - filename, output, template, debug, +def request_from_args(args) -> ConvertRequest: + return ConvertRequest( + filename=args.filename or "", + output=args.output, + template=args.template, doc_type=args.doc_type, heading_numbering=args.heading_numbering, - emdash_to_hyphen=args.emdash_to_hyphen, toc_mode=args.toc, table_continuation=args.table_continuation, + listing_continuation=args.listing_continuation, + table_repeat_header=bool(getattr(args, "table_repeat_header", False)), + emdash_to_hyphen=args.emdash_to_hyphen, + hr_pagebreak=args.hr_pagebreak, + title=args.title, + assignment=args.assignment, + check=args.check, + check_only=args.check_only, + check_pages=bool(getattr(args, "check_pages", False)), + strict=args.strict, + syntax_highlighting=bool(args.syntax_highlighting), + plantuml_jar=args.plantuml_jar, + kroki_url=args.kroki_url, + diagram_fallback=args.diagram_fallback, + diagram_format=args.diagram_format, + diagram_scale=float(args.diagram_scale), + schemes_path=args.schemes_path, + debug=args.debug, + open_when_done=bool(args.debug), ) - converter.convert() - document = converter.document - # Front matter is appended *into* a shell that already has coursework styles. - # Never use title.docx as compose base: python-docx default template has - # Calibri + accent-blue headings and would override ГОСТ стили. - if args.title or args.assignment: +def _argv_needs_console(argv: list[str]) -> bool: + """Frozen windowed exe: attach a console for CLI / --help, not for GUI.""" + args = argv[1:] + if not args: + return False + if "-h" in args or "--help" in args: + return True + if "--gui" in args: + return False + return any(not a.startswith("-") for a in args) + + +def _enable_windows_console() -> None: + if sys.platform != "win32" or not getattr(sys, "frozen", False): + return + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + if not kernel32.AttachConsole(0xFFFFFFFF): + if not kernel32.GetConsoleWindow(): + kernel32.AllocConsole() + sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace") + sys.stderr = open("CONOUT$", "w", encoding="utf-8", errors="replace") try: - from docxcompose.composer import Composer - except ImportError: - print("Error: docxcompose required for --title/--assignment") - sys.exit(3) - from .styles import apply_document_styles + sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace") + except OSError: + pass + except Exception: + pass - shell = Document(template) - apply_document_styles(shell, get_profile(args.doc_type).style_preset) - body = shell.element.body - for child in list(body): - if not child.tag.endswith("}sectPr"): - body.remove(child) - composer = Composer(shell) - if args.title: - composer.append(Document(args.title)) - shell.add_page_break() - if args.assignment: - composer.append(Document(args.assignment)) - shell.add_page_break() - composer.append(document) - document = composer.doc - apply_document_styles(document, get_profile(args.doc_type).style_preset) +def main(): + if _argv_needs_console(sys.argv): + _enable_windows_console() + parser = build_parser() + args = parser.parse_args() + req = request_from_args(args) - document.core_properties.author = getuser() - document.core_properties.comments = \ - "Создано при помощи md2gost (ТЗ МИРЭА)" + if should_launch_gui(args.filename, args.gui): + from .gui import run_gui + run_gui(req) + return - document.save(output) - print(f"Generated document: {os.path.abspath(output)}") - - if debug: - import platform - if platform.system() == 'Darwin': - import subprocess - subprocess.call(('open', output)) - elif platform.system() == 'Windows': - os.startfile(output) - else: - import subprocess - subprocess.call(('xdg-open', output)) + result = convert(req) + sys.exit(result.exit_code) if __name__ == "__main__": diff --git a/md2gost/checker/__init__.py b/md2gost/checker/__init__.py index e1b80b3..ce2ea11 100644 --- a/md2gost/checker/__init__.py +++ b/md2gost/checker/__init__.py @@ -258,12 +258,36 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]: n = len(entries) if profile.sectional_biblio: - # Count per ## section roughly - if n < profile.min_sources: + # Count per ## section inside bibliography + sections: list[tuple[str, int]] = [] + current_title: str | None = None + current_count = 0 + for line in block.splitlines(): + hm = re.match(r"^#{2,6}\s+(\*?)(.+)$", line) + if hm: + if current_title is not None: + sections.append((current_title, current_count)) + current_title = hm.group(2).strip() + current_count = 0 + continue + if BIBLIO_LINE_RE.match(line.strip()): + current_count += 1 + if current_title is not None: + sections.append((current_title, current_count)) + + if sections: + for title, cnt in sections: + if cnt < profile.min_sources: + issues.append(Issue( + "biblio.count", "warning", + f"ВКР: в разделе списка «{title}» желательно ≥{profile.min_sources} " + f"источников (сейчас {cnt})", + )) + elif n < profile.min_sources: issues.append(Issue( "biblio.count", "warning", - f"ВКР: в каждом разделе списка желательно ≥{profile.min_sources} источников " - f"(сейчас всего {n})", + f"ВКР: список должен делиться на разделы; в каждом ≥{profile.min_sources} " + f"источников (сейчас всего {n}, разделов нет)", )) else: if n < profile.min_sources: @@ -288,13 +312,10 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]: )) # Citation order vs first appearance - # Exclude intro/conclusion from citation scan for order body_for_cites = text cites = find_citations(body_for_cites) - # Filter cites that appear only in intro/conclusion — still listed keys = [e[0] for e in entries] if cites and keys: - # First N unique cites should match order of keys for simple lists if not profile.sectional_biblio: expected = cites[:len(keys)] if keys != expected and set(keys) == set(expected): @@ -313,16 +334,15 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]: def check_object_refs(text: str) -> list[Issue]: issues = [] - # Captions / images labels = set(re.findall(r"^%(\w+)", text, re.M)) - # image titles with %id labels.update(re.findall(r'!\[[^\]]*\]\([^)]*%(\w+)', text)) refs = set(re.findall(r"@[\wА-Яа-я]+:(\w+)", text)) for lab in labels: - # At least one @?:lab or word reference — soft check - if lab not in refs and f"@{lab}" not in text: - # only warn if label looks intentional - pass + if lab not in refs: + issues.append(Issue( + "ref.unused", "warning", + f"Метка «{lab}» объявлена, но в тексте нет ссылки @…:{lab}", + )) for ref in refs: if ref not in labels: issues.append(Issue( @@ -342,6 +362,66 @@ def check_appendices(text: str) -> list[Issue]: f"Буква «{letter}» не используется для обозначения приложений", _line_of(text, m.start()), )) + + # Multiple lettered appendices should have a list after # *ПРИЛОЖЕНИЯ + apps = list(re.finditer( + r"^#{1,6}\s+\*?Приложение\s+[А-ЯA-ZЁ]\b", + text, re.M | re.I, + )) + # Filter out the section header «ПРИЛОЖЕНИЯ» / bare «ПРИЛОЖЕНИЕ» + lettered = [] + for m in apps: + line = m.group(0) + if re.search(r"(?i)приложение\s+[А-ЯA-ZЁ]", line): + lettered.append(m) + if len(lettered) >= 2: + sec = re.search( + r"^#\s*\*?\s*ПРИЛОЖЕНИЯ?\s*$", + text, re.M | re.I, + ) + if sec: + between = text[sec.end(): lettered[0].start()] + # Expect a plain-text list (not only blank / headings) + has_list = bool(re.search(r"(?im)^\s*[-–—*]|\bприложение\s+[А-ЯA-ZЁ]\b", between)) + has_prose = bool(re.search(r"[А-Яа-яA-Za-z]{3,}", between)) + if not (has_list or has_prose): + issues.append(Issue( + "appendix.toc", "warning", + "При нескольких приложениях после «ПРИЛОЖЕНИЯ» нужен перечень " + "(основной текст) с номерами и названиями", + _line_of(text, sec.start()), + )) + return issues + + +def check_continuation_hints( + text: str, + *, + table_continuation: str = "off", + listing_continuation: str = "off", +) -> list[Issue]: + """Warn that Word cannot auto-insert «Продолжение…» when mode is off/soft.""" + issues: list[Issue] = [] + soft = {"off", "soft"} + if table_continuation in soft and _TABLE_BLOCK_RE.search(text): + issues.append(Issue( + "table.continuation", "warning", + "Word сам не вставит «Продолжение Таблицы N» при переносе. " + "Варианты: --table-continuation word (точный разрыв через Word COM) " + "или caption (оценка высоты ≠ вёрстка Word, возможны артефакты).", + )) + # Long fenced code blocks (likely listings) + long_listing = False + for m in re.finditer(r"^```[^\n]*\n([\s\S]*?)^```", text, re.M): + if m.group(1).count("\n") >= 40: + long_listing = True + break + if listing_continuation in soft and long_listing: + issues.append(Issue( + "listing.continuation", "warning", + "Длинный листинг: Word сам не вставит «Продолжение Листинга N». " + "Варианты: --listing-continuation word или caption.", + )) return issues @@ -404,7 +484,13 @@ def check_table_merge(text: str) -> list[Issue]: return issues -def check_markdown(text: str, doc_type: str = "coursework") -> list[Issue]: +def check_markdown( + text: str, + doc_type: str = "coursework", + *, + table_continuation: str = "off", + listing_continuation: str = "off", +) -> list[Issue]: profile = get_profile(doc_type) issues: list[Issue] = [] issues.extend(check_structure(text, profile)) @@ -417,6 +503,11 @@ def check_markdown(text: str, doc_type: str = "coursework") -> list[Issue]: issues.extend(check_object_refs(text)) issues.extend(check_appendices(text)) issues.extend(check_table_merge(text)) + issues.extend(check_continuation_hints( + text, + table_continuation=table_continuation, + listing_continuation=listing_continuation, + )) return issues diff --git a/md2gost/converter.py b/md2gost/converter.py index 17053ba..548eec1 100644 --- a/md2gost/converter.py +++ b/md2gost/converter.py @@ -15,7 +15,9 @@ from .profiles import ( DEFAULT_TOC_MODE, TOC_MODES, DEFAULT_TABLE_CONTINUATION, + DEFAULT_LISTING_CONTINUATION, TABLE_CONTINUATION_MODES, + LISTING_CONTINUATION_MODES, ) from .label_pass import ( assign_numbers, @@ -26,6 +28,8 @@ from .label_pass import ( from .renderable.heading import Heading from .renderable.toc import ToC from .renderable.table import Table +from .renderable.listing import Listing +from .renderable.diagram import DiagramFigure class Converter: @@ -33,11 +37,13 @@ class Converter: def __init__(self, input_path: str, output_path: str, template_path: str = None, debug: bool = False, - doc_type: str = "coursework", + doc_type: str = "practice", heading_numbering: str = DEFAULT_HEADING_NUMBERING, emdash_to_hyphen: bool = False, toc_mode: str = DEFAULT_TOC_MODE, - table_continuation: str = DEFAULT_TABLE_CONTINUATION): + table_continuation: str = DEFAULT_TABLE_CONTINUATION, + listing_continuation: str = DEFAULT_LISTING_CONTINUATION, + hr_pagebreak: bool = False): if heading_numbering not in HEADING_NUMBERING_MODES: raise ValueError( f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, " @@ -52,12 +58,19 @@ class Converter: f"table_continuation must be one of {TABLE_CONTINUATION_MODES}, " f"got {table_continuation!r}" ) + if listing_continuation not in LISTING_CONTINUATION_MODES: + raise ValueError( + f"listing_continuation must be one of {LISTING_CONTINUATION_MODES}, " + f"got {listing_continuation!r}" + ) self._output_path = output_path self._doc_type = doc_type self._heading_numbering = heading_numbering self._emdash_to_hyphen = emdash_to_hyphen self._toc_mode = toc_mode self._table_continuation = table_continuation + self._listing_continuation = listing_continuation + self._hr_pagebreak = hr_pagebreak self._profile = get_profile(doc_type) self._document: Document = docx.Document(template_path) self._document._body.clear_content() @@ -67,7 +80,7 @@ class Converter: raw = f.read() self._raw_markdown = raw self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen) - self.parser = Parser(self._document, self._preprocessed) + self.parser = Parser(self._document, self._preprocessed, hr_pagebreak=hr_pagebreak) def convert(self): renderables = list(self.parser.parse()) @@ -85,6 +98,10 @@ class Converter: r.set_toc_mode(self._toc_mode) elif isinstance(r, Table): r.set_continuation_mode(self._table_continuation) + elif isinstance(r, Listing): + r.set_continuation_mode(self._listing_continuation) + elif isinstance(r, DiagramFigure) and r.listing: + r.listing.set_continuation_mode(self._listing_continuation) formula_refs = find_formula_refs(self._raw_markdown) registry = assign_numbers( diff --git a/md2gost/diagram_includes.py b/md2gost/diagram_includes.py new file mode 100644 index 0000000..7f44609 --- /dev/null +++ b/md2gost/diagram_includes.py @@ -0,0 +1,261 @@ +"""HTTP(S) include cache: index URL → local file, never overwrite existing cache files.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +import sys +from pathlib import Path +from urllib.parse import urlparse, urljoin + +import requests + +_log = logging.getLogger(__name__) + +INCLUDE_CACHE_INDEX = "md2gost.include-cache.json" +INCLUDE_CACHE_DIR = "include-cache" +MAX_INCLUDE_DEPTH = 8 + +_HTTP_RE = re.compile(r"^https?://", re.I) +_INCLUDE_LINE_RE = re.compile( + r"^[ \t]*!(?:includeurl|include)[ \t]+(?P[\"']?)(?P[^\s\"']+)(?P=q)", + re.I | re.M, +) + + +def app_dir() -> Path: + """Directory next to the running application (exe or cwd for python -m).""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path.cwd() + + +def include_cache_index_path(base: Path | None = None) -> Path: + return (base or app_dir()) / INCLUDE_CACHE_INDEX + + +def include_cache_files_dir(base: Path | None = None) -> Path: + return (base or app_dir()) / INCLUDE_CACHE_DIR + + +def is_http_url(value: str) -> bool: + return bool(_HTTP_RE.match((value or "").strip())) + + +def _load_index(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _log.warning("Не удалось прочитать индекс кэша includes: %s", exc) + return {} + if not isinstance(data, dict): + return {} + out: dict[str, str] = {} + for key, val in data.items(): + if isinstance(key, str) and isinstance(val, str) and is_http_url(key): + out[key] = val + return out + + +def _save_index(path: Path, index: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(index, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _unique_cache_path(url: str, files_dir: Path) -> Path: + """New file path for URL; never reuse/overwrite an existing cache file.""" + files_dir.mkdir(parents=True, exist_ok=True) + parsed = urlparse(url) + base = Path(parsed.path).name or "include.puml" + if not base.lower().endswith((".puml", ".iuml", ".pu", ".txt")): + base = base + ".puml" if "." not in base else base + digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:10] + stem = Path(base).stem + suffix = Path(base).suffix or ".puml" + # sanitize stem + safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", stem)[:40] or "include" + candidate = files_dir / f"{safe}_{digest}{suffix}" + if not candidate.exists(): + return candidate + n = 1 + while True: + alt = files_dir / f"{safe}_{digest}_{n}{suffix}" + if not alt.exists(): + return alt + n += 1 + + +def _download(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_suffix(dest.suffix + ".part") + try: + resp = requests.get(url, timeout=60) + resp.raise_for_status() + tmp.write_bytes(resp.content) + tmp.replace(dest) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +def resolve_url( + url: str, + *, + base: Path | None = None, + depth: int = 0, +) -> Path: + """ + Return local path for URL. Uses index if present and file exists; + otherwise downloads to a *new* file and records the mapping. + Never overwrites an existing cache file for a different URL. + """ + url = url.strip() + if not is_http_url(url): + raise ValueError(f"Не URL: {url}") + if depth > MAX_INCLUDE_DEPTH: + raise RuntimeError(f"Слишком глубокая цепочка includes ({MAX_INCLUDE_DEPTH}): {url}") + + root = base or app_dir() + index_path = include_cache_index_path(root) + files_dir = include_cache_files_dir(root) + index = _load_index(index_path) + + cached = index.get(url) + if cached: + path = Path(cached) + if path.is_file(): + _rewrite_nested_includes(path, parent_url=url, base=root, depth=depth) + return path + _log.warning("Файл кэша пропал (%s), скачаю заново: %s", path, url) + + dest = _unique_cache_path(url, files_dir) + try: + _download(url, dest) + except Exception as exc: + raise RuntimeError( + f"Не удалось скачать include {url}: {exc}. " + "Нет сети и нет кэша — положите файл вручную или проверьте URL." + ) from exc + + index[url] = str(dest.resolve()) + _save_index(index_path, index) + _log.info("Include cached: %s → %s", url, dest) + _rewrite_nested_includes(dest, parent_url=url, base=root, depth=depth) + return dest + + +def _absolute_include_url(ref: str, parent_url: str | None) -> str | None: + """Turn include ref into absolute http(s) URL, or None if local/stdlib.""" + ref = ref.strip() + if is_http_url(ref): + return ref + if ref.startswith("<") and ref.endswith(">"): + return None # PlantUML stdlib + if parent_url and not Path(ref).is_absolute(): + # relative to parent URL directory + return urljoin(parent_url, ref) + return None + + +def _rewrite_nested_includes( + path: Path, *, parent_url: str, base: Path, depth: int, +) -> None: + """Rewrite http(s) / relative !include inside a cached .puml to local paths.""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return + + changed = False + + def repl(match: re.Match[str]) -> str: + nonlocal changed + ref = match.group("ref") + abs_url = _absolute_include_url(ref, parent_url) + if not abs_url: + return match.group(0) + local = resolve_url(abs_url, base=base, depth=depth + 1) + changed = True + local_s = str(local.resolve()).replace("\\", "/") + return f"!include {local_s}" + + new_text = _INCLUDE_LINE_RE.sub(repl, text) + if changed and new_text != text: + path.write_text(new_text, encoding="utf-8") + + +def resolve_include_ref( + ref: str, + *, + base: Path | None = None, + schemes_dir: Path | None = None, +) -> str: + """ + Resolve include entry to a local path string suitable for !include. + Local paths are resolved relative to schemes_dir / diagrams / cwd. + """ + ref = (ref or "").strip() + if not ref: + raise ValueError("Пустой include") + if is_http_url(ref): + return str(resolve_url(ref, base=base).resolve()).replace("\\", "/") + + path = Path(ref) + if path.is_file(): + return str(path.resolve()).replace("\\", "/") + candidates: list[Path] = [] + if schemes_dir: + candidates.append(schemes_dir / ref) + from . import package_dir + candidates.append(Path(package_dir()) / "diagrams" / ref) + candidates.append(Path.cwd() / ref) + for cand in candidates: + if cand.is_file(): + return str(cand.resolve()).replace("\\", "/") + raise FileNotFoundError(f"Include не найден: {ref}") + + +def rewrite_http_includes_in_source(source: str, *, base: Path | None = None) -> str: + """Replace http(s) !include / !includeurl in prepared source with cached local paths.""" + + def repl(match: re.Match[str]) -> str: + ref = match.group("ref") + if not is_http_url(ref): + return match.group(0) + local = str(resolve_url(ref, base=base).resolve()).replace("\\", "/") + return f"!include {local}" + + return _INCLUDE_LINE_RE.sub(repl, source) + + +def reset_include_cache(base: Path | None = None) -> int: + """ + Delete index and only files listed in it. Returns number of files removed. + Other files in include-cache/ are left untouched. + """ + root = base or app_dir() + index_path = include_cache_index_path(root) + index = _load_index(index_path) + removed = 0 + for _url, path_s in list(index.items()): + path = Path(path_s) + try: + if path.is_file(): + path.unlink() + removed += 1 + except OSError as exc: + _log.warning("Не удалось удалить %s: %s", path, exc) + if index_path.is_file(): + index_path.unlink(missing_ok=True) + return removed + + +def cache_entry_count(base: Path | None = None) -> int: + return len(_load_index(include_cache_index_path(base or app_dir()))) diff --git a/md2gost/diagram_renderer.py b/md2gost/diagram_renderer.py index 6c82140..1d9f811 100644 --- a/md2gost/diagram_renderer.py +++ b/md2gost/diagram_renderer.py @@ -1,12 +1,14 @@ -"""Render UML/BPMN/C4 fenced blocks to PNG (PlantUML jar / Kroki local / remote).""" +"""Render UML / Mermaid / scheme fenced blocks to PNG (+ optional SVG).""" from __future__ import annotations import hashlib import logging import os +import re import shutil import subprocess +import sys import tempfile from dataclasses import dataclass from pathlib import Path @@ -15,13 +17,45 @@ from typing import Literal import requests from . import package_dir +from .diagram_schemes import ( + configure_schemes, + ensure_user_schemes, + is_diagram_lang, + prepare_with_schemes, +) -DIAGRAM_LANGS = frozenset({"uml", "plantuml", "bpmn", "c4"}) +# Back-compat: static set used by older tests; runtime uses is_diagram_lang(). +DIAGRAM_LANGS = frozenset( + { + "uml", + "plantuml", + "c4", + "c4context", + "c4component", + "usecase", + "bpmn", + "mermaid", + "mmd", + } +) FallbackMode = Literal["local", "remote", "off"] +DiagramFormat = Literal["png", "svg"] DEFAULT_KROKI_URL = "http://localhost:8000" REMOTE_KROKI_URL = "https://kroki.io" +# Sharper raster when Word shrinks the figure to page width (display size stays ×1). +DEFAULT_DIAGRAM_SCALE = 2.0 +# Avoid PlantUML clipping large scaled diagrams (default limit is 4096). +PLANTUML_LIMIT_SIZE = "8192" + +# Pinned official jar (GPL). Bundled at exe build; otherwise downloaded once to user cache. +PLANTUML_VERSION = "1.2025.4" +PLANTUML_JAR_URL = ( + f"https://github.com/plantuml/plantuml/releases/download/" + f"v{PLANTUML_VERSION}/plantuml-{PLANTUML_VERSION}.jar" +) + _log = logging.getLogger(__name__) @@ -31,6 +65,17 @@ class DiagramConfig: kroki_url: str | None = None fallback: FallbackMode = "remote" cache_dir: str | None = None + schemes_path: str | None = None + diagram_format: DiagramFormat = "png" + diagram_scale: float = DEFAULT_DIAGRAM_SCALE + + +@dataclass +class DiagramRenderResult: + png_path: str + svg_path: str | None = None + # PlantUML render scale; Image divides display size by this to keep ×1 layout. + pixel_scale: float = 1.0 _CONFIG = DiagramConfig() @@ -41,13 +86,26 @@ def configure_diagrams( kroki_url: str | None = None, fallback: FallbackMode = "remote", cache_dir: str | None = None, + schemes_path: str | None = None, + md_dir: str | None = None, + diagram_format: DiagramFormat = "png", + diagram_scale: float | None = None, ) -> None: global _CONFIG + ensure_user_schemes() + configure_schemes(schemes_path=schemes_path, md_dir=md_dir) + fmt: DiagramFormat = "svg" if diagram_format == "svg" else "png" + scale = DEFAULT_DIAGRAM_SCALE if diagram_scale is None else float(diagram_scale) + if scale < 1: + scale = 1.0 _CONFIG = DiagramConfig( - plantuml_jar=plantuml_jar or os.environ.get("PLANTUML_JAR"), + plantuml_jar=resolve_plantuml_jar(plantuml_jar or None, download=False), kroki_url=kroki_url or os.environ.get("KROKI_URL"), fallback=fallback, cache_dir=cache_dir, + schemes_path=schemes_path, + diagram_format=fmt, + diagram_scale=scale, ) @@ -57,65 +115,175 @@ def diagrams_dir() -> Path: def prepare_source(lang: str, source: str) -> tuple[str, str]: """Return (prepared_source, kroki_diagram_type).""" - lang = (lang or "uml").lower().strip() - text = source.strip() - - if lang == "bpmn": - if "@startbpmn" not in text.lower() and "\n" - ) - body = text - if body.lower().startswith("@startuml"): - lines = body.splitlines() - text = lines[0] + "\n" + includes + "\n".join(lines[1:]) - else: - text = f"@startuml\n{includes}{body}\n@enduml" - elif not text.lower().startswith("@start"): - text = f"@startuml\n{text}\n@enduml" - return text, "plantuml" - - # uml / plantuml - if not text.lower().startswith("@start"): - text = f"@startuml\n{text}\n@enduml" - return text, "plantuml" + return prepare_with_schemes(lang, source) -def _cache_path(source: str, cache_dir: str | None = None) -> Path: +def _cache_root(cache_dir: str | None = None) -> Path: root = cache_dir or _CONFIG.cache_dir if not root: wd = os.environ.get("WORKING_DIR", ".") root = os.path.join(wd, ".md2gost-cache") - Path(root).mkdir(parents=True, exist_ok=True) - digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:24] - return Path(root) / f"{digest}.png" + path = Path(root) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _cache_digest(source: str) -> str: + return hashlib.sha256(source.encode("utf-8")).hexdigest()[:24] + + +def _cache_paths(source: str, cache_dir: str | None = None) -> tuple[Path, Path]: + root = _cache_root(cache_dir) + digest = _cache_digest(source) + return root / f"{digest}.png", root / f"{digest}.svg" def _java_available() -> bool: return shutil.which("java") is not None -def _render_plantuml_jar(source: str, out_png: Path, jar: str) -> bool: +def vendor_plantuml_path() -> Path: + return Path(package_dir()) / "vendor" / "plantuml.jar" + + +def cached_plantuml_path() -> Path: + if os.name == "nt": + root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost" + else: + root = Path.home() / ".md2gost" + return root / "plantuml.jar" + + +def iter_plantuml_candidates(explicit: str | None = None) -> list[Path]: + paths: list[Path] = [] + if explicit: + paths.append(Path(explicit)) + env = os.environ.get("PLANTUML_JAR") + if env: + paths.append(Path(env)) + paths.append(vendor_plantuml_path()) + if getattr(sys, "frozen", False): + mei = getattr(sys, "_MEIPASS", None) + if mei: + paths.append(Path(mei) / "md2gost" / "vendor" / "plantuml.jar") + paths.append(Path(mei) / "vendor" / "plantuml.jar") + paths.append(Path(sys.executable).resolve().parent / "plantuml.jar") + paths.append(cached_plantuml_path()) + seen: set[str] = set() + out: list[Path] = [] + for path in paths: + key = str(path) + if key in seen: + continue + seen.add(key) + out.append(path) + return out + + +def resolve_plantuml_jar(explicit: str | None = None, *, download: bool = False) -> str | None: + """Find a plantuml.jar: explicit path, env, bundled vendor, user cache; optionally download.""" + for path in iter_plantuml_candidates(explicit): + if path.is_file() and path.stat().st_size > 1000: + return str(path) + if download: + dest = cached_plantuml_path() + if fetch_plantuml_jar(dest): + return str(dest) + return None + + +def fetch_plantuml_jar(dest: Path, url: str = PLANTUML_JAR_URL) -> bool: + """Download official plantuml.jar to dest. Returns True on success.""" + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_suffix(".jar.part") + try: + resp = requests.get(url, timeout=120, stream=True) + resp.raise_for_status() + size = 0 + with open(tmp, "wb") as fh: + for chunk in resp.iter_content(chunk_size=65536): + if chunk: + fh.write(chunk) + size += len(chunk) + if size < 1000: + tmp.unlink(missing_ok=True) + return False + tmp.replace(dest) + _log.info("PlantUML jar: %s", dest) + return True + except Exception as exc: + _log.warning("Не удалось скачать plantuml.jar: %s", exc) + tmp.unlink(missing_ok=True) + return False + + +def diagram_engine_status(explicit_jar: str | None = None) -> str: + """Human-readable status for the GUI.""" + java = shutil.which("java") + jar = resolve_plantuml_jar(explicit_jar, download=False) + if java: + java_line = f"Java: есть ({java})" + else: + java_line = ( + "Java: не найдена. Локальный PlantUML не запустится — " + "картинки пойдут через интернет (kroki.io)." + ) + if jar: + jar_line = f"PlantUML: {jar}" + else: + jar_line = ( + "PlantUML: файла нет. Нажмите «Скачать PlantUML» (нужна Java) " + "или оставьте как есть — картинки нарисует интернет (kroki.io)." + ) + return java_line + "\n" + jar_line + + +def _ensure_plantuml_png_scale(source: str, scale: float = DEFAULT_DIAGRAM_SCALE) -> str: + """Inject `scale N` into PlantUML source unless the author already set scale/dpi.""" + if scale <= 1: + return source + if re.search(r"(?im)^\s*scale\b", source): + return source + if re.search(r"(?im)^\s*skinparam\s+dpi\b", source): + return source + lines = source.splitlines() + if not lines: + return source + insert_at = 0 + if lines[0].strip().lower().startswith("@start"): + insert_at = 1 + scale_line = f"scale {scale:g}" + lines.insert(insert_at, scale_line) + return "\n".join(lines) + ("\n" if source.endswith("\n") else "") + + +def _is_png(data: bytes) -> bool: + return data.startswith(b"\x89PNG") + + +def _is_svg(data: bytes) -> bool: + head = data.lstrip()[:200].lower() + return head.startswith(b" bool: if not jar or not os.path.isfile(jar) or not _java_available(): return False with tempfile.TemporaryDirectory() as tmp: src = Path(tmp) / "diagram.puml" src.write_text(source, encoding="utf-8") cmd = [ - "java", "-jar", jar, - "-tpng", + "java", + f"-Dplantuml.include.path={diagrams_dir().resolve()}", + f"-DPLANTUML_LIMIT_SIZE={PLANTUML_LIMIT_SIZE}", + "-jar", jar, + f"-t{fmt}", "-charset", "UTF-8", - f"-I{diagrams_dir()}", "-o", tmp, str(src), ] @@ -126,16 +294,43 @@ def _render_plantuml_jar(source: str, out_png: Path, jar: str) -> bool: except (OSError, subprocess.TimeoutExpired) as e: _log.warning("PlantUML jar failed: %s", e) return False - produced = Path(tmp) / "diagram.png" + produced = Path(tmp) / f"diagram.{fmt}" if proc.returncode != 0 or not produced.is_file(): _log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout) return False - shutil.copyfile(produced, out_png) + data = produced.read_bytes() + if fmt == "png" and not _is_png(data): + return False + if fmt == "svg" and not _is_svg(data): + return False + shutil.copyfile(produced, out_path) return True -def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path) -> bool: - url = base_url.rstrip("/") + f"/{diagram_type}/png" +def _render_plantuml_jar( + source: str, + out_png: Path, + jar: str, + *, + out_svg: Path | None = None, +) -> bool: + """Render PNG; optionally also SVG from the same jar.""" + if not _render_plantuml_jar_one(source, out_png, jar, "png"): + return False + if out_svg is not None: + if not _render_plantuml_jar_one(source, out_svg, jar, "svg"): + _log.warning("PlantUML jar: PNG ok, SVG failed") + return True + + +def _render_kroki( + source: str, + diagram_type: str, + base_url: str, + out_path: Path, + fmt: Literal["png", "svg"] = "png", +) -> bool: + url = base_url.rstrip("/") + f"/{diagram_type}/{fmt}" try: resp = requests.post( url, @@ -143,16 +338,38 @@ def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path) headers={"Content-Type": "text/plain"}, timeout=60, ) - if resp.status_code != 200 or not resp.content.startswith(b"\x89PNG"): + if resp.status_code != 200: _log.warning("Kroki %s → HTTP %s", url, resp.status_code) return False - out_png.write_bytes(resp.content) + data = resp.content + if fmt == "png" and not _is_png(data): + _log.warning("Kroki %s → not PNG", url) + return False + if fmt == "svg" and not _is_svg(data): + _log.warning("Kroki %s → not SVG", url) + return False + out_path.write_bytes(data) return True except requests.RequestException as e: _log.warning("Kroki request failed (%s): %s", url, e) return False +def _render_kroki_pair( + source: str, + diagram_type: str, + base_url: str, + out_png: Path, + out_svg: Path | None, +) -> bool: + if not _render_kroki(source, diagram_type, base_url, out_png, "png"): + return False + if out_svg is not None: + if not _render_kroki(source, diagram_type, base_url, out_svg, "svg"): + _log.warning("Kroki: PNG ok, SVG failed (%s)", base_url) + return True + + def render_diagram( lang: str, source: str, @@ -161,36 +378,91 @@ def render_diagram( kroki_url: str | None = None, fallback: FallbackMode | None = None, cache_dir: str | None = None, -) -> str: - """Render diagram to PNG path. Raises RuntimeError if all backends fail.""" + diagram_format: DiagramFormat | None = None, + diagram_scale: float | None = None, +) -> DiagramRenderResult: + """Render diagram to PNG (+ optional SVG). Raises RuntimeError if all backends fail.""" + ensure_user_schemes() prepared, kroki_type = prepare_source(lang, source) - out = _cache_path(prepared, cache_dir) - if out.is_file() and out.stat().st_size > 0: - return str(out) + scale = _CONFIG.diagram_scale if diagram_scale is None else float(diagram_scale) + if scale < 1: + scale = 1.0 + # pixel_scale: only when we injected scale (author override → treat as 1 for display). + pixel_scale = 1.0 + if kroki_type == "plantuml" and scale > 1: + before = prepared + prepared = _ensure_plantuml_png_scale(prepared, scale) + if prepared != before: + pixel_scale = scale + out_png, out_svg_path = _cache_paths(prepared, cache_dir) + fmt: DiagramFormat = ( + diagram_format + if diagram_format is not None + else _CONFIG.diagram_format + ) + want_svg = fmt == "svg" + svg_target = out_svg_path if want_svg else None + + if out_png.is_file() and out_png.stat().st_size > 0: + has_svg = out_svg_path.is_file() and out_svg_path.stat().st_size > 0 + if not want_svg or has_svg: + return DiagramRenderResult( + png_path=str(out_png), + svg_path=str(out_svg_path) if (want_svg and has_svg) else None, + pixel_scale=pixel_scale, + ) + # PNG cached but SVG missing in svg mode — fall through to fill SVG. jar = plantuml_jar if plantuml_jar is not None else _CONFIG.plantuml_jar - jar = jar or os.environ.get("PLANTUML_JAR") + jar = resolve_plantuml_jar(jar or None, download=False) local_kroki = kroki_url if kroki_url is not None else _CONFIG.kroki_url local_kroki = local_kroki or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL mode: FallbackMode = fallback if fallback is not None else _CONFIG.fallback - # 1) PlantUML jar - if _render_plantuml_jar(prepared, out, jar or ""): - return str(out) + def _result() -> DiagramRenderResult: + svg = None + if want_svg and out_svg_path.is_file() and out_svg_path.stat().st_size > 0: + svg = str(out_svg_path) + return DiagramRenderResult( + png_path=str(out_png), svg_path=svg, pixel_scale=pixel_scale, + ) + + # 1) PlantUML jar — only for plantuml type + if kroki_type == "plantuml": + if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target): + return _result() # 2) Local Kroki - if _render_kroki(prepared, kroki_type, local_kroki, out): - return str(out) + if _render_kroki_pair(prepared, kroki_type, local_kroki, out_png, svg_target): + return _result() + + def _png_fallback_or_raise(message: str) -> DiagramRenderResult: + if out_png.is_file() and out_png.stat().st_size > 0: + _log.warning("Диаграмма: SVG недоступен, вставляю только PNG") + return DiagramRenderResult( + png_path=str(out_png), svg_path=None, pixel_scale=pixel_scale, + ) + raise RuntimeError(message) if mode == "off": - raise RuntimeError( - "Не удалось отрендерить диаграмму локально " - "(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)" + if kroki_type == "plantuml": + return _png_fallback_or_raise( + "Не удалось отрендерить диаграмму локально " + "(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)" + ) + return _png_fallback_or_raise( + "Не удалось отрендерить диаграмму через локальный Kroki " + "(задайте --kroki-url / KROKI_URL; remote fallback отключён)" ) if mode == "local": - raise RuntimeError( - "Локальный рендер диаграммы недоступен " - "(Java+plantuml.jar или локальный Kroki)" + if kroki_type == "plantuml": + return _png_fallback_or_raise( + "Локальный рендер диаграммы недоступен " + "(Java+plantuml.jar или локальный Kroki)" + ) + return _png_fallback_or_raise( + "Локальный Kroki недоступен для диаграммы " + f"(тип {kroki_type}; задайте работающий --kroki-url)" ) # 3) Remote fallback @@ -198,7 +470,26 @@ def render_diagram( "Диаграмма: локальный рендер недоступен, использую remote %s", REMOTE_KROKI_URL, ) - if _render_kroki(prepared, kroki_type, REMOTE_KROKI_URL, out): - return str(out) + if _render_kroki_pair(prepared, kroki_type, REMOTE_KROKI_URL, out_png, svg_target): + return _result() - raise RuntimeError("Не удалось отрендерить диаграмму (PlantUML/Kroki)") + return _png_fallback_or_raise( + "Не удалось отрендерить диаграмму (PlantUML/Kroki)" + ) + + +# Re-export for callers / tests +__all__ = [ + "DIAGRAM_LANGS", + "DiagramRenderResult", + "configure_diagrams", + "diagram_engine_status", + "diagrams_dir", + "fetch_plantuml_jar", + "is_diagram_lang", + "prepare_source", + "render_diagram", + "resolve_plantuml_jar", + "PLANTUML_JAR_URL", + "PLANTUML_VERSION", +] diff --git a/md2gost/diagram_schemes.py b/md2gost/diagram_schemes.py new file mode 100644 index 0000000..f1f2c78 --- /dev/null +++ b/md2gost/diagram_schemes.py @@ -0,0 +1,335 @@ +"""Configurable PlantUML diagram schemes (uml-c4, …) from md2gost.schemes.json.""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from . import package_dir +from .diagram_includes import ( + app_dir, + resolve_include_ref, + rewrite_http_includes_in_source, +) + +_log = logging.getLogger(__name__) + +SCHEMES_FILENAME = "md2gost.schemes.json" +SCHEME_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$") +BUILTIN_DIAGRAM_LANGS = frozenset({"uml", "plantuml"}) +# Rendered via Kroki only (not PlantUML jar / schemes). +KROKI_DIAGRAM_LANGS = frozenset({"mermaid", "mmd"}) +KROKI_TYPE_BY_LANG = {"mermaid": "mermaid", "mmd": "mermaid"} + + +@dataclass +class DiagramScheme: + id: str + title: str = "" + version: str = "" + author: str = "" + docs: str = "" + ai_prompt: str = "" + includes: list[str] = field(default_factory=list) + prefix: str = "" + postfix: str = "" + theme: str = "" + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "title": self.title, + "version": self.version, + "author": self.author, + "docs": self.docs, + "ai-prompt": self.ai_prompt, + "includes": list(self.includes), + "prefix": self.prefix, + "postfix": self.postfix, + } + if self.theme: + data["theme"] = self.theme + return data + + @classmethod + def from_dict(cls, scheme_id: str, data: dict[str, Any]) -> DiagramScheme: + includes = data.get("includes") or [] + if isinstance(includes, str): + includes = [includes] + return cls( + id=scheme_id, + title=str(data.get("title") or scheme_id), + version=str(data.get("version") or ""), + author=str(data.get("author") or ""), + docs=str(data.get("docs") or ""), + ai_prompt=str(data.get("ai-prompt") or data.get("ai_prompt") or ""), + includes=[str(x) for x in includes], + prefix=str(data.get("prefix") or ""), + postfix=str(data.get("postfix") or ""), + theme=str(data.get("theme") or ""), + ) + + +def bundled_schemes_path() -> Path: + return Path(package_dir()) / "diagrams" / "schemes.json" + + +def user_schemes_path(base: Path | None = None) -> Path: + return (base or app_dir()) / SCHEMES_FILENAME + + +def ensure_user_schemes(base: Path | None = None) -> Path: + """ + On first run copy bundled template next to the app. + Never overwrite an existing user file. + """ + dest = user_schemes_path(base) + if dest.is_file(): + return dest + src = bundled_schemes_path() + dest.parent.mkdir(parents=True, exist_ok=True) + if src.is_file(): + shutil.copyfile(src, dest) + _log.info("Создан файл схем: %s", dest) + else: + dest.write_text("{}\n", encoding="utf-8") + _log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest) + return dest + + +def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]: + if not path.is_file(): + return {} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _log.warning("Не удалось прочитать схемы %s: %s", path, exc) + return {} + if not isinstance(raw, dict): + return {} + out: dict[str, DiagramScheme] = {} + for key, val in raw.items(): + sid = str(key).lower().strip() + if not SCHEME_ID_RE.match(sid): + _log.warning("Пропуск схемы с недопустимым id: %s", key) + continue + if not isinstance(val, dict): + continue + out[sid] = DiagramScheme.from_dict(sid, val) + return out + + +def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]: + return _load_schemes_file(path) + + +def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path: + dest = path or user_schemes_path() + payload = {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())} + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return dest + + +def scheme_id_from_lang(lang: str) -> str | None: + """Normalize fence lang to scheme id, or None for bare uml/plantuml.""" + lang = (lang or "").lower().strip() + if not lang: + return None + if lang in BUILTIN_DIAGRAM_LANGS: + return None + if lang.startswith("uml-"): + sid = lang[4:] + return sid if SCHEME_ID_RE.match(sid) else None + if SCHEME_ID_RE.match(lang): + return lang + return None + + +def load_merged_schemes( + *, + base: Path | None = None, + extra_path: Path | None = None, + md_dir: Path | None = None, +) -> dict[str, DiagramScheme]: + """ + Merge layers (later wins entirely per id): + 1) bundled template + 2) user file next to app (created on first run) + 3) md2gost.schemes.json next to .md + 4) explicit --schemes path + """ + ensure_user_schemes(base) + merged: dict[str, DiagramScheme] = {} + merged.update(_load_schemes_file(bundled_schemes_path())) + merged.update(_load_schemes_file(user_schemes_path(base))) + if md_dir: + near_md = Path(md_dir) / SCHEMES_FILENAME + if near_md.is_file(): + user = user_schemes_path(base).resolve() + try: + if near_md.resolve() != user: + merged.update(_load_schemes_file(near_md)) + except OSError: + merged.update(_load_schemes_file(near_md)) + if extra_path: + merged.update(_load_schemes_file(Path(extra_path))) + return merged + + +_SCHEMES_CACHE: dict[str, DiagramScheme] | None = None +_SCHEMES_EXTRA: Path | None = None +_SCHEMES_MD_DIR: Path | None = None + + +def configure_schemes( + *, + schemes_path: str | None = None, + md_dir: str | None = None, + base: Path | None = None, +) -> dict[str, DiagramScheme]: + global _SCHEMES_CACHE, _SCHEMES_EXTRA, _SCHEMES_MD_DIR + _SCHEMES_EXTRA = Path(schemes_path) if schemes_path else None + _SCHEMES_MD_DIR = Path(md_dir) if md_dir else None + _SCHEMES_CACHE = load_merged_schemes( + base=base, + extra_path=_SCHEMES_EXTRA, + md_dir=_SCHEMES_MD_DIR, + ) + return _SCHEMES_CACHE + + +def get_schemes() -> dict[str, DiagramScheme]: + global _SCHEMES_CACHE + if _SCHEMES_CACHE is None: + _SCHEMES_CACHE = load_merged_schemes() + return _SCHEMES_CACHE + + +def reload_schemes(**kwargs) -> dict[str, DiagramScheme]: + return configure_schemes(**kwargs) + + +def get_scheme(scheme_id: str) -> DiagramScheme | None: + return get_schemes().get(scheme_id) + + +def is_diagram_lang(lang: str) -> bool: + """True if fence should render as a diagram (not a plain listing).""" + lang = (lang or "").lower().strip() + if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS: + return True + if lang.startswith("uml-"): + # Force diagram path so missing scheme becomes an error, not a listing. + return bool(SCHEME_ID_RE.match(lang[4:])) + return lang in get_schemes() + + +def apply_scheme( + scheme: DiagramScheme, + body: str, + *, + base: Path | None = None, +) -> str: + """Wrap body with includes / prefix / postfix / theme; resolve URL includes.""" + text = body.strip() + has_start = text.lower().startswith("@start") + + include_lines: list[str] = [] + schemes_dir = user_schemes_path(base).parent + for ref in scheme.includes: + local = resolve_include_ref(ref, base=base, schemes_dir=schemes_dir) + include_lines.append(f"!include {local}") + + theme_line = "" + if scheme.theme and "!theme" not in text.lower(): + t = scheme.theme.strip() + if not t.lower().startswith("!theme"): + t = f"!theme {t}" + theme_line = t + + prefix = scheme.prefix or "" + postfix = scheme.postfix or "" + + # Avoid double @startuml / @enduml when body already has @start… + if has_start: + prefix_use = _strip_start_end_wrappers(prefix) + postfix_use = _strip_start_end_wrappers(postfix) + head_bits = [ln for ln in include_lines if ln] + if theme_line: + head_bits.append(theme_line) + if prefix_use.strip(): + head_bits.append(prefix_use.strip()) + if head_bits: + lines = text.splitlines() + text = lines[0] + "\n" + "\n".join(head_bits) + "\n" + "\n".join(lines[1:]) + if postfix_use.strip(): + text = text.rstrip() + "\n" + postfix_use.strip() + else: + parts: list[str] = [] + p = prefix.rstrip("\n") if prefix else "" + pf = postfix.lstrip("\n") if postfix else "" + if not p.lstrip().lower().startswith("@start"): + parts.append("@startuml") + if p: + parts.append(p) + parts.extend(include_lines) + if theme_line: + parts.append(theme_line) + parts.append(text) + if pf: + parts.append(pf) + elif "@enduml" not in text.lower(): + parts.append("@enduml") + text = "\n".join(parts) + + text = rewrite_http_includes_in_source(text, base=base) + return text + + +def _strip_start_end_wrappers(chunk: str) -> str: + """Remove leading @start… and trailing @end… lines from prefix/postfix.""" + lines = chunk.splitlines() + while lines and lines[0].strip().lower().startswith("@start"): + lines = lines[1:] + while lines and lines[-1].strip().lower().startswith("@end"): + lines = lines[:-1] + return "\n".join(lines) + + +def prepare_with_schemes(lang: str, source: str, *, base: Path | None = None) -> tuple[str, str]: + """ + Prepare diagram source using schemes. + Returns (prepared_source, kroki_diagram_type). + Raises ValueError if uml- / named scheme is missing. + """ + lang = (lang or "uml").lower().strip() + text = source.strip() + + if lang in KROKI_DIAGRAM_LANGS: + return text, KROKI_TYPE_BY_LANG[lang] + + sid = scheme_id_from_lang(lang) + + if sid is None: + # bare uml / plantuml + if not text.lower().startswith("@start"): + text = f"@startuml\n{text}\n@enduml" + text = rewrite_http_includes_in_source(text, base=base) + return text, "plantuml" + + scheme = get_scheme(sid) + if scheme is None: + raise ValueError( + f"Схема диаграммы «{sid}» не найдена " + f"(оградка ```{lang}). Добавьте её в {SCHEMES_FILENAME}." + ) + prepared = apply_scheme(scheme, text, base=base) + return prepared, "plantuml" diff --git a/md2gost/diagrams/BPMN.puml b/md2gost/diagrams/BPMN.puml new file mode 100644 index 0000000..02b8c5b --- /dev/null +++ b/md2gost/diagrams/BPMN.puml @@ -0,0 +1,1126 @@ +' md2gost BPMN 2.0 macros for PlantUML +' Notation: OMG BPMN 2.0.2 (formal/2013-12-09), Ch. 7 graphical elements, +' Ch. 10 Process (events, activities, gateways), Ch. 9 Collaboration (pools, message flow). +' PlantUML cannot attach boundary events to the activity border or draw a true +' double-circle; thickness + stereotypes approximate start / intermediate / end. +' Gateways use hexagon — in this renderer it reads as a BPMN diamond. +' +' Usage (scheme ```bpmn / ```uml-bpmn wraps @startuml + this include): +' Pool(company, "Компания") { +' Lane(op, "Оператор") { +' Start(s) +' UserTask(t, "Проверить") +' XOR(gw) +' End(e) +' Flow(s, t) +' Flow(t, gw) +' CondFlow(gw, e, "нет") +' } +' } + +!ifndef BPMN_PUML +!define BPMN_PUML + +hide stereotype +skinparam shadowing false +skinparam linetype polyline +skinparam nodesep 28 +skinparam ranksep 42 +skinparam defaultTextAlignment center +skinparam wrapWidth 140 +skinparam ArrowColor #212121 +skinparam Padding 2 + +' --------------------------------------------------------------------------- +' Sprites (event / task markers, BPMN 2.0 Table 7.2–7.4) +' --------------------------------------------------------------------------- + +sprite $bpmnMsg [16x11/16] { +0000000000000000 +0FFFFFFFFFFFFFF0 +0F000000000000F0 +0FF0000000000FF0 +0F0FF000000FF0F0 +0F000FFFFFF000F0 +0F0000FFFF0000F0 +0F000000000000F0 +0F000000000000F0 +0FFFFFFFFFFFFFF0 +0000000000000000 +} + +sprite $bpmnMsgFill [16x11/16] { +0000000000000000 +0FFFFFFFFFFFFFF0 +0FFFFFFFFFFFFFF0 +0FFFFFFFFFFFFFF0 +0FFFFFFFFFFFFFF0 +0FFF0FFFFFF0FFF0 +0FF000FFFF000FF0 +0F00000FF00000F0 +0F000000000000F0 +0FFFFFFFFFFFFFF0 +0000000000000000 +} + +sprite $bpmnTimer [16x10/16] { +000000FFFF000000 +0000FF0000FF0000 +000F000FF000F000 +00F0000FF0000F00 +00F0000FF0000F00 +00F0000FFFF00F00 +00F0000000000F00 +000F00000000F000 +0000FF0000FF0000 +000000FFFF000000 +} + +sprite $bpmnError [16x9/16] { +0000000FF0000000 +000000FF00000000 +00000FFFFF000000 +0000FF0000000000 +000FFFFF00000000 +000000FF00000000 +00000FF000000000 +0000FF0000000000 +000FF00000000000 +} + +sprite $bpmnSignal [16x8/16] { +00000000F0000000 +0000000FFF000000 +000000FF0FF00000 +00000FF000FF0000 +0000FF00000FF000 +000FF0000000FF00 +00FF000000000FF0 +0FFFFFFFFFFFFFFF +} + +sprite $bpmnSignalFill [16x8/16] { +00000000F0000000 +0000000FFF000000 +000000FFFFF00000 +00000FFFFFFF0000 +0000FFFFFFFFF000 +000FFFFFFFFFFF00 +00FFFFFFFFFFFFF0 +0FFFFFFFFFFFFFFF +} + +sprite $bpmnTerminate [16x9/16] { +00000FFFFFF00000 +000FFFFFFFFFF000 +00FFFFFFFFFFFF00 +0FFFFFFFFFFFFFF0 +0FFFFFFFFFFFFFF0 +0FFFFFFFFFFFFFF0 +00FFFFFFFFFFFF00 +000FFFFFFFFFF000 +00000FFFFFF00000 +} + +sprite $bpmnEscalation [16x9/16] { +00000000F0000000 +0000000FFF000000 +000000FF0FF00000 +00000FF000FF0000 +0000FFFFFFFFF000 +000000F000F00000 +000000F000F00000 +000000F000F00000 +000000FFFFF00000 +} + +sprite $bpmnComp [16x7/16] { +00F0000F00000000 +0FF000FF00000000 +FFF00FFF00000000 +FFFF0FFFF0000000 +FFF00FFF00000000 +0FF000FF00000000 +00F0000F00000000 +} + +sprite $bpmnLink [16x7/16] { +0000000000000000 +00000000000F0000 +00000000000FF000 +0FFFFFFFFFFFFF00 +00000000000FF000 +00000000000F0000 +0000000000000000 +} + +sprite $bpmnCond [16x10/16] { +0FFFFFFFFFF00000 +0F00000000FF0000 +0F00000000F0F000 +0F00000000FFF000 +0F00FFFF0000F000 +0F0000000000F000 +0F00FFFF0000F000 +0F0000000000F000 +0F00FFFF0000F000 +0FFFFFFFFFFFF000 +} + +sprite $bpmnMulti [16x8/16] { +00000000F0000000 +000000FF0FF00000 +00000F00000F0000 +0000F0000000F000 +000F000000000F00 +000F000000000F00 +0000F0000000F000 +00000FFFFFFF0000 +} + +sprite $bpmnParMulti [16x9/16] { +00000FFFFFF00000 +000FF000000FF000 +00F0000FF0000F00 +0F00000FF00000F0 +0F00FFFFFFFF00F0 +0F00000FF00000F0 +00F0000FF0000F00 +000FF000000FF000 +00000FFFFFF00000 +} + +sprite $bpmnCancel [16x11/16] { +0FF0000000000FF0 +00FF00000000FF00 +000FF000000FF000 +0000FF0000FF0000 +00000FF00FF00000 +000000FFFF000000 +00000FF00FF00000 +0000FF0000FF0000 +000FF000000FF000 +00FF00000000FF00 +0FF0000000000FF0 +} + +sprite $bpmnUser [16x10/16] { +000000FFFF000000 +00000FFFFFF00000 +00000FFFFFF00000 +000000FFFF000000 +0000000000000000 +0000FFFFFFFF0000 +000FF000000FF000 +000FF000000FF000 +000FF000000FF000 +000FF000000FF000 +} + +sprite $bpmnService [16x9/16] { +000000FFFF000000 +0000FFF00FFF0000 +000FF0FFFF0FF000 +00FF00FFFF00FF00 +00F0000000000F00 +00FF00FFFF00FF00 +000FF0FFFF0FF000 +0000FFF00FFF0000 +000000FFFF000000 +} + +sprite $bpmnScript [16x9/16] { +0FFFFFFFFFF00000 +0F00000000FF0000 +0F00000000F0F000 +0F00FFFF00FFF000 +0F0000000000F000 +0F00FFFF0000F000 +0F0000000000F000 +0F00FFFF0000F000 +0FFFFFFFFFFFF000 +} + +sprite $bpmnManual [16x7/16] { +000000000FF00000 +00000000FFFF0000 +0000FF0FF0FF0000 +000FFFFFFFFFF000 +000FFFFFFFFFF000 +0000FFFFFFFF0000 +00000FFFFFF00000 +} + +sprite $bpmnRule [16x7/16] { +0FFFFFFFFFFFFFF0 +0F000F000F0000F0 +0FFFFFFFFFFFFFF0 +0F000F000F0000F0 +0FFFFFFFFFFFFFF0 +0F000F000F0000F0 +0FFFFFFFFFFFFFF0 +} + +sprite $bpmnPlusBox [12x9/16] { +FFFFFFFFFFFF +F0000000000F +F0000FF0000F +F0000FF0000F +F0FFFFFFFF0F +F0000FF0000F +F0000FF0000F +F0000000000F +FFFFFFFFFFFF +} + +sprite $bpmnLoop [16x7/16] { +0000FFFFFF000000 +000F000000F00000 +00F000000000000F +0F00000000000FF0 +0F000000000F0000 +00F0000000F00000 +000FFFFFF0000000 +} + +sprite $bpmnAdhoc [16x5/16] { +0000000000000000 +0FF0000FFF000FF0 +000FF0F000FF0000 +00000F00000F0000 +0000000000000000 +} + +sprite $bpmnMIPar [13x5/16] { +00F000F000F00 +00F000F000F00 +00F000F000F00 +00F000F000F00 +00F000F000F00 +} + +sprite $bpmnMISeq [13x7/16] { +0000000000000 +0FFFFFFFFFFF0 +0000000000000 +0FFFFFFFFFFF0 +0000000000000 +0FFFFFFFFFFF0 +0000000000000 +} + +' --------------------------------------------------------------------------- +' Skin (colours are conventional, not mandated by the spec) +' --------------------------------------------------------------------------- + +skinparam circle<> { + BackgroundColor #C8E6C9 + BorderColor #2E7D32 + BorderThickness 1.5 + FontSize 10 +} + +skinparam circle<> { + BackgroundColor #C8E6C9 + BorderColor #2E7D32 + BorderThickness 1.5 + BorderStyle dashed + FontSize 10 +} + +skinparam circle<> { + BackgroundColor #FFF3E0 + BorderColor #EF6C00 + BorderThickness 2 + FontSize 10 +} + +skinparam circle<> { + BackgroundColor #FFE0B2 + BorderColor #E65100 + BorderThickness 2 + FontSize 10 +} + +skinparam circle<> { + BackgroundColor #FFCDD2 + BorderColor #B71C1C + BorderThickness 3.5 + FontSize 10 +} + +skinparam circle<> { + BackgroundColor #FFF3E0 + BorderColor #EF6C00 + BorderThickness 2 + FontSize 9 +} + +skinparam circle<> { + BackgroundColor #FFF3E0 + BorderColor #EF6C00 + BorderThickness 2 + BorderStyle dashed + FontSize 9 +} + +skinparam hexagon<> { + BackgroundColor #FFF8E1 + BorderColor #F9A825 + BorderThickness 1.5 + FontSize 12 +} + +skinparam rectangle<> { + RoundCorner 22 + BackgroundColor #FFFFFF + BorderColor #1565C0 + BorderThickness 1.4 + FontSize 11 +} + +skinparam rectangle<> { + RoundCorner 22 + BackgroundColor #FAFAFA + BorderColor #1565C0 + BorderThickness 1.4 + FontSize 11 +} + +skinparam rectangle<> { + RoundCorner 22 + BackgroundColor #FFFFFF + BorderColor #0D47A1 + BorderThickness 3 + FontSize 11 +} + +skinparam rectangle<> { + RoundCorner 22 + BackgroundColor #FAFAFA + BorderColor #0D47A1 + BorderThickness 2.5 + FontSize 11 +} + +skinparam rectangle<> { + RoundCorner 22 + BackgroundColor #FAFAFA + BorderColor #6A1B9A + BorderThickness 1.4 + BorderStyle dashed + FontSize 11 +} + +skinparam rectangle<> { + RoundCorner 0 + BackgroundColor #F5F5F5 + BorderColor #212121 + BorderThickness 1.6 + FontSize 12 + FontStyle bold +} + +skinparam rectangle<> { + RoundCorner 0 + BackgroundColor #FFFFFF + BorderColor #757575 + BorderThickness 1 + FontSize 11 +} + +skinparam rectangle<> { + RoundCorner 0 + BackgroundColor #EEEEEE + BorderColor #212121 + BorderThickness 1.6 + FontSize 12 + FontStyle bold +} + +skinparam rectangle<> { + RoundCorner 8 + BackgroundColor transparent + BorderColor #9E9E9E + BorderThickness 1.2 + BorderStyle dashed + FontSize 10 +} + +skinparam file<> { + BackgroundColor #E3F2FD + BorderColor #1565C0 + FontSize 10 +} + +skinparam database<> { + BackgroundColor #E8F5E9 + BorderColor #2E7D32 + FontSize 10 +} + +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F9A825 + FontSize 10 +} + +' --------------------------------------------------------------------------- +' Internals +' --------------------------------------------------------------------------- + +!unquoted procedure $bpmnCircle($alias, $stereo, $icon, $label) + !if ($icon == "") + !if ($label == "") + circle " " as $alias <<$stereo>> + !else + circle "$label" as $alias <<$stereo>> + !endif + !else + !if ($label == "") + circle "<$$icon>" as $alias <<$stereo>> + !else + circle "<$$icon>\n$label" as $alias <<$stereo>> + !endif + !endif +!endprocedure + +!unquoted procedure $bpmnGW($alias, $mark, $label) + !if ($label == "") + hexagon "$mark" as $alias <> + !else + hexagon "$mark\n$label" as $alias <> + !endif +!endprocedure + +!unquoted procedure $bpmnRect($alias, $stereo, $icon, $label, $marker="") + !if ($icon == "") + !if ($marker == "") + rectangle "$label" as $alias <<$stereo>> + !else + rectangle "$label\n\n<$$marker>" as $alias <<$stereo>> + !endif + !else + !if ($marker == "") + rectangle "<$$icon> $label" as $alias <<$stereo>> + !else + rectangle "<$$icon> $label\n\n<$$marker>" as $alias <<$stereo>> + !endif + !endif +!endprocedure + +' --------------------------------------------------------------------------- +' Swimlanes — BPMN 2.0 §9.2 Pool / §9.3 Lane +' Pool(...) { ... } Lane(...) { ... } +' --------------------------------------------------------------------------- + +!unquoted procedure Pool($alias, $label) + rectangle "$label" as $alias <> +!endprocedure + +!unquoted procedure Lane($alias, $label) + rectangle "$label" as $alias <> +!endprocedure + +!unquoted procedure BlackBoxPool($alias, $label) + rectangle "$label" as $alias <> +!endprocedure + +!unquoted procedure Participant($alias, $label) + BlackBoxPool($alias, $label) +!endprocedure + +!unquoted procedure Group($alias, $label) + rectangle "$label" as $alias <> +!endprocedure + +' --------------------------------------------------------------------------- +' Start events — §10.5.2 (None, Message, Timer, Conditional, Signal, Error, +' Escalation, Multiple, Parallel Multiple). Error/Escalation only in event sub-process. +' --------------------------------------------------------------------------- + +!unquoted procedure Start($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "", $label) +!endprocedure + +!unquoted procedure StartEvent($alias, $label="") + Start($alias, $label) +!endprocedure + +!unquoted procedure StartMessage($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnMsg", $label) +!endprocedure + +!unquoted procedure StartTimer($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnTimer", $label) +!endprocedure + +!unquoted procedure StartCondition($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnCond", $label) +!endprocedure + +!unquoted procedure StartConditional($alias, $label="") + StartCondition($alias, $label) +!endprocedure + +!unquoted procedure StartSignal($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnSignal", $label) +!endprocedure + +!unquoted procedure StartError($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnError", $label) +!endprocedure + +!unquoted procedure StartEscalation($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnEscalation", $label) +!endprocedure + +!unquoted procedure StartMultiple($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnMulti", $label) +!endprocedure + +!unquoted procedure StartParallelMultiple($alias, $label="") + $bpmnCircle($alias, "bpmnStart", "bpmnParMulti", $label) +!endprocedure + +!unquoted procedure StartNI($alias, $label="") + $bpmnCircle($alias, "bpmnStartNI", "", $label) +!endprocedure + +!unquoted procedure StartMessageNI($alias, $label="") + $bpmnCircle($alias, "bpmnStartNI", "bpmnMsg", $label) +!endprocedure + +!unquoted procedure StartTimerNI($alias, $label="") + $bpmnCircle($alias, "bpmnStartNI", "bpmnTimer", $label) +!endprocedure + +!unquoted procedure StartSignalNI($alias, $label="") + $bpmnCircle($alias, "bpmnStartNI", "bpmnSignal", $label) +!endprocedure + +' --------------------------------------------------------------------------- +' Intermediate catching / throwing — §10.5.4 +' --------------------------------------------------------------------------- + +!unquoted procedure Intermediate($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "", $label) +!endprocedure + +!unquoted procedure CatchMessage($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnMsg", $label) +!endprocedure + +!unquoted procedure ThrowMessage($alias, $label="") + $bpmnCircle($alias, "bpmnThrow", "bpmnMsgFill", $label) +!endprocedure + +!unquoted procedure CatchTimer($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnTimer", $label) +!endprocedure + +!unquoted procedure CatchSignal($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnSignal", $label) +!endprocedure + +!unquoted procedure ThrowSignal($alias, $label="") + $bpmnCircle($alias, "bpmnThrow", "bpmnSignalFill", $label) +!endprocedure + +!unquoted procedure CatchError($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnError", $label) +!endprocedure + +!unquoted procedure CatchEscalation($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnEscalation", $label) +!endprocedure + +!unquoted procedure ThrowEscalation($alias, $label="") + $bpmnCircle($alias, "bpmnThrow", "bpmnEscalation", $label) +!endprocedure + +!unquoted procedure CatchCondition($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnCond", $label) +!endprocedure + +!unquoted procedure CatchLink($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnLink", $label) +!endprocedure + +!unquoted procedure ThrowLink($alias, $label="") + $bpmnCircle($alias, "bpmnThrow", "bpmnLink", $label) +!endprocedure + +!unquoted procedure CatchCompensation($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnComp", $label) +!endprocedure + +!unquoted procedure ThrowCompensation($alias, $label="") + $bpmnCircle($alias, "bpmnThrow", "bpmnComp", $label) +!endprocedure + +!unquoted procedure CatchCancel($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnCancel", $label) +!endprocedure + +!unquoted procedure CatchMultiple($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnMulti", $label) +!endprocedure + +!unquoted procedure ThrowMultiple($alias, $label="") + $bpmnCircle($alias, "bpmnThrow", "bpmnMulti", $label) +!endprocedure + +!unquoted procedure CatchParallelMultiple($alias, $label="") + $bpmnCircle($alias, "bpmnInter", "bpmnParMulti", $label) +!endprocedure + +' --------------------------------------------------------------------------- +' Boundary events — interrupting (solid) / non-interrupting (dashed). Attach() +' --------------------------------------------------------------------------- + +!unquoted procedure BoundaryMessage($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnMsg", $label) +!endprocedure + +!unquoted procedure BoundaryTimer($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnTimer", $label) +!endprocedure + +!unquoted procedure BoundaryError($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnError", $label) +!endprocedure + +!unquoted procedure BoundaryEscalation($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnEscalation", $label) +!endprocedure + +!unquoted procedure BoundarySignal($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnSignal", $label) +!endprocedure + +!unquoted procedure BoundaryCancel($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnCancel", $label) +!endprocedure + +!unquoted procedure BoundaryCompensation($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnComp", $label) +!endprocedure + +!unquoted procedure BoundaryCondition($alias, $label="") + $bpmnCircle($alias, "bpmnBoundary", "bpmnCond", $label) +!endprocedure + +!unquoted procedure BoundaryMessageNI($alias, $label="") + $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnMsg", $label) +!endprocedure + +!unquoted procedure BoundaryTimerNI($alias, $label="") + $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnTimer", $label) +!endprocedure + +!unquoted procedure BoundaryEscalationNI($alias, $label="") + $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnEscalation", $label) +!endprocedure + +!unquoted procedure BoundarySignalNI($alias, $label="") + $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnSignal", $label) +!endprocedure + +!unquoted procedure BoundaryConditionNI($alias, $label="") + $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnCond", $label) +!endprocedure + +!unquoted procedure Attach($activity, $event) + $activity -[hidden]down- $event +!endprocedure + +!unquoted procedure Attach_R($activity, $event) + $activity -[hidden]right- $event +!endprocedure + +' --------------------------------------------------------------------------- +' End events — §10.5.3 (None, Message, Error, Escalation, Cancel, Compensation, +' Signal, Multiple, Terminate) +' --------------------------------------------------------------------------- + +!unquoted procedure End($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "", $label) +!endprocedure + +!unquoted procedure EndEvent($alias, $label="") + End($alias, $label) +!endprocedure + +!unquoted procedure EndMessage($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnMsgFill", $label) +!endprocedure + +!unquoted procedure EndError($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnError", $label) +!endprocedure + +!unquoted procedure EndEscalation($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnEscalation", $label) +!endprocedure + +!unquoted procedure EndCancel($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnCancel", $label) +!endprocedure + +!unquoted procedure EndCompensation($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnComp", $label) +!endprocedure + +!unquoted procedure EndSignal($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnSignalFill", $label) +!endprocedure + +!unquoted procedure EndMultiple($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnMulti", $label) +!endprocedure + +!unquoted procedure EndTerminate($alias, $label="") + $bpmnCircle($alias, "bpmnEnd", "bpmnTerminate", $label) +!endprocedure + +!unquoted procedure Terminate($alias, $label="") + EndTerminate($alias, $label) +!endprocedure + +' --------------------------------------------------------------------------- +' Activities — §10.3 Task types + markers (loop, MI, compensation, ad-hoc) +' --------------------------------------------------------------------------- + +!unquoted procedure Task($alias, $label, $type="none", $marker="") + !if ($type == "user") + $bpmnRect($alias, "bpmnTask", "bpmnUser", $label, $marker) + !elseif ($type == "service") + $bpmnRect($alias, "bpmnTask", "bpmnService", $label, $marker) + !elseif ($type == "script") + $bpmnRect($alias, "bpmnTask", "bpmnScript", $label, $marker) + !elseif ($type == "manual") + $bpmnRect($alias, "bpmnTask", "bpmnManual", $label, $marker) + !elseif ($type == "send") + $bpmnRect($alias, "bpmnTask", "bpmnMsgFill", $label, $marker) + !elseif ($type == "receive") + $bpmnRect($alias, "bpmnTask", "bpmnMsg", $label, $marker) + !elseif ($type == "rule") + $bpmnRect($alias, "bpmnTask", "bpmnRule", $label, $marker) + !elseif ($type == "businessrule") + $bpmnRect($alias, "bpmnTask", "bpmnRule", $label, $marker) + !else + $bpmnRect($alias, "bpmnTask", "", $label, $marker) + !endif +!endprocedure + +!unquoted procedure UserTask($alias, $label, $marker="") + Task($alias, $label, "user", $marker) +!endprocedure + +!unquoted procedure ServiceTask($alias, $label, $marker="") + Task($alias, $label, "service", $marker) +!endprocedure + +!unquoted procedure ScriptTask($alias, $label, $marker="") + Task($alias, $label, "script", $marker) +!endprocedure + +!unquoted procedure ManualTask($alias, $label, $marker="") + Task($alias, $label, "manual", $marker) +!endprocedure + +!unquoted procedure SendTask($alias, $label, $marker="") + Task($alias, $label, "send", $marker) +!endprocedure + +!unquoted procedure ReceiveTask($alias, $label, $marker="") + Task($alias, $label, "receive", $marker) +!endprocedure + +!unquoted procedure BusinessRuleTask($alias, $label, $marker="") + Task($alias, $label, "rule", $marker) +!endprocedure + +!unquoted procedure LoopTask($alias, $label) + Task($alias, $label, "none", "bpmnLoop") +!endprocedure + +!unquoted procedure SubProcess($alias, $label) + $bpmnRect($alias, "bpmnSubProcess", "", $label, "bpmnPlusBox") +!endprocedure + +!unquoted procedure SubProcessExpanded($alias, $label) + rectangle "$label" as $alias <> +!endprocedure + +!unquoted procedure CallActivity($alias, $label) + $bpmnRect($alias, "bpmnCall", "", $label, "bpmnPlusBox") +!endprocedure + +!unquoted procedure Transaction($alias, $label) + $bpmnRect($alias, "bpmnTransaction", "", $label, "bpmnPlusBox") +!endprocedure + +!unquoted procedure EventSubProcess($alias, $label) + rectangle "$label" as $alias <> +!endprocedure + +!unquoted procedure AdHocSubProcess($alias, $label) + $bpmnRect($alias, "bpmnSubProcess", "", $label, "bpmnAdhoc") +!endprocedure + +' --------------------------------------------------------------------------- +' Gateways — §10.6 (Exclusive, Parallel, Inclusive, Complex, Event-Based) +' --------------------------------------------------------------------------- + +!unquoted procedure Exclusive($alias, $label="") + $bpmnGW($alias, "×", $label) +!endprocedure + +!unquoted procedure XOR($alias, $label="") + Exclusive($alias, $label) +!endprocedure + +!unquoted procedure Xor($alias, $label="") + Exclusive($alias, $label) +!endprocedure + +!unquoted procedure Gateway($alias, $label="") + Exclusive($alias, $label) +!endprocedure + +!unquoted procedure Parallel($alias, $label="") + $bpmnGW($alias, "+", $label) +!endprocedure + +!unquoted procedure AND($alias, $label="") + Parallel($alias, $label) +!endprocedure + +!unquoted procedure And($alias, $label="") + Parallel($alias, $label) +!endprocedure + +!unquoted procedure Inclusive($alias, $label="") + $bpmnGW($alias, "○", $label) +!endprocedure + +!unquoted procedure OR($alias, $label="") + Inclusive($alias, $label) +!endprocedure + +!unquoted procedure Or($alias, $label="") + Inclusive($alias, $label) +!endprocedure + +!unquoted procedure ComplexGW($alias, $label="") + $bpmnGW($alias, "*", $label) +!endprocedure + +!unquoted procedure Complex($alias, $label="") + ComplexGW($alias, $label) +!endprocedure + +!unquoted procedure EventBased($alias, $label="") + $bpmnGW($alias, "◇", $label) +!endprocedure + +!unquoted procedure EventBasedGateway($alias, $label="") + EventBased($alias, $label) +!endprocedure + +!unquoted procedure ExclusiveEventBased($alias, $label="") + $bpmnGW($alias, "◇", $label) +!endprocedure + +!unquoted procedure ParallelEventBased($alias, $label="") + $bpmnGW($alias, "⊕", $label) +!endprocedure + +' --------------------------------------------------------------------------- +' Connecting objects — §8.4.13 Sequence Flow, Message Flow, Association +' Default = slash; conditional = mini-diamond (label prefixes as stand-in) +' --------------------------------------------------------------------------- + +!unquoted procedure Flow($from, $to, $label="") + !if ($label == "") + $from --> $to + !else + $from --> $to : $label + !endif +!endprocedure + +!unquoted procedure Flow_R($from, $to, $label="") + !if ($label == "") + $from -right-> $to + !else + $from -right-> $to : $label + !endif +!endprocedure + +!unquoted procedure Flow_L($from, $to, $label="") + !if ($label == "") + $from -left-> $to + !else + $from -left-> $to : $label + !endif +!endprocedure + +!unquoted procedure Flow_D($from, $to, $label="") + !if ($label == "") + $from -down-> $to + !else + $from -down-> $to : $label + !endif +!endprocedure + +!unquoted procedure Flow_U($from, $to, $label="") + !if ($label == "") + $from -up-> $to + !else + $from -up-> $to : $label + !endif +!endprocedure + +!unquoted procedure SequenceFlow($from, $to, $label="") + Flow($from, $to, $label) +!endprocedure + +!unquoted procedure CondFlow($from, $to, $cond) + $from --> $to : ◇ $cond +!endprocedure + +!unquoted procedure DefaultFlow($from, $to, $label="") + !if ($label == "") + $from --> $to : ╱ + !else + $from --> $to : ╱ $label + !endif +!endprocedure + +!unquoted procedure MessageFlow($from, $to, $label="") + !if ($label == "") + $from ..> $to + !else + $from ..> $to : $label + !endif +!endprocedure + +!unquoted procedure MessageFlow_R($from, $to, $label="") + !if ($label == "") + $from -[dashed]right-> $to + !else + $from -[dashed]right-> $to : $label + !endif +!endprocedure + +!unquoted procedure Assoc($from, $to, $label="") + !if ($label == "") + $from .. $to + !else + $from .. $to : $label + !endif +!endprocedure + +!unquoted procedure Association($from, $to, $label="") + Assoc($from, $to, $label) +!endprocedure + +!unquoted procedure DataAssoc($from, $to, $label="") + !if ($label == "") + $from ..> $to + !else + $from ..> $to : $label + !endif +!endprocedure + +' --------------------------------------------------------------------------- +' Data / artifacts — §7.1.1.7 Data, Group, Text Annotation +' --------------------------------------------------------------------------- + +!unquoted procedure DataObject($alias, $label) + file "$label" as $alias <> +!endprocedure + +!unquoted procedure DataInput($alias, $label) + file "▷ $label" as $alias <> +!endprocedure + +!unquoted procedure DataOutput($alias, $label) + file "$label ▷" as $alias <> +!endprocedure + +!unquoted procedure Collection($alias, $label) + file "$label\n<$bpmnMIPar>" as $alias <> +!endprocedure + +!unquoted procedure DataStore($alias, $label) + database "$label" as $alias <> +!endprocedure + +!unquoted procedure Annotation($alias, $text) + note "$text" as $alias +!endprocedure + +!unquoted procedure TextAnnotation($alias, $text) + Annotation($alias, $text) +!endprocedure + +' --------------------------------------------------------------------------- +' Layout helpers +' --------------------------------------------------------------------------- + +!unquoted procedure Lay_R($from, $to) + $from -[hidden]right-> $to +!endprocedure + +!unquoted procedure Lay_L($from, $to) + $from -[hidden]left-> $to +!endprocedure + +!unquoted procedure Lay_D($from, $to) + $from -[hidden]down-> $to +!endprocedure + +!unquoted procedure Lay_U($from, $to) + $from -[hidden]up-> $to +!endprocedure + +!procedure BPMN_HORIZONTAL() + left to right direction +!endprocedure + +!procedure BPMN_VERTICAL() + top to bottom direction +!endprocedure + +!procedure BPMN_ORTHO() + skinparam linetype ortho +!endprocedure + +!procedure BPMN_POLYLINE() + skinparam linetype polyline +!endprocedure + +!procedure BPMN_LEGEND() +legend right + BPMN 2.0 + |= Элемент |= Смысл | + | тонкий круг | Start Event | + | двойная толщина | Intermediate | + | жирный круг | End Event | + | ● внутри конца | Terminate | + | скруглённый прямоугольник | Activity / Task | + | ромб × | Exclusive (XOR) | + | ромб + | Parallel (AND) | + | ромб ○ | Inclusive (OR) | + | ромб ◇ | Event-Based | + | сплошная стрелка | Sequence Flow | + | пунктир | Message Flow | + | ╱ | Default Flow | + | ◇ на стрелке | Conditional Flow | +endlegend +!endprocedure + +!endif diff --git a/md2gost/diagrams/schemes.json b/md2gost/diagrams/schemes.json new file mode 100644 index 0000000..e786982 --- /dev/null +++ b/md2gost/diagrams/schemes.json @@ -0,0 +1,60 @@ +{ + "c4": { + "title": "C4 Container", + "version": "1.0.0", + "author": "md2gost", + "docs": "Person(alias, \"Label\", \"descr?\")\nSystem(alias, \"Label\", \"descr?\")\nSystem_Ext(alias, \"Label\")\nContainer(alias, \"Label\", \"techn\", \"descr?\")\nContainerDb(alias, \"Label\", \"techn\")\nRel(from, to, \"label\", \"techn?\")\nRel_R / Rel_L / Rel_U / Rel_D — направление\nLAYOUT_WITH_LEGEND()", + "ai-prompt": "Рисуй C4 Container diagram. В блоке ```uml-c4 (или ```c4) пиши только макросы C4: Person, System, Container, Rel. Без @startuml и без !include — схема добавит сама.", + "includes": [ + "https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml" + ], + "prefix": "@startuml\n", + "postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml" + }, + "c4context": { + "title": "C4 Context", + "version": "1.0.0", + "author": "md2gost", + "docs": "Person(alias, \"Label\")\nSystem(alias, \"Label\", \"descr?\")\nSystem_Ext(alias, \"Label\")\nSystem_Boundary(alias, \"Label\") { … }\nRel(from, to, \"label\")\nLAYOUT_WITH_LEGEND()", + "ai-prompt": "Рисуй C4 System Context. В блоке ```uml-c4context только макросы C4 Context. Без @startuml и !include.", + "includes": [ + "https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml" + ], + "prefix": "@startuml\n", + "postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml" + }, + "c4component": { + "title": "C4 Component", + "version": "1.0.0", + "author": "md2gost", + "docs": "Container_Boundary(alias, \"Label\") { … }\nComponent(alias, \"Label\", \"techn\", \"descr?\")\nComponentDb(alias, \"Label\", \"techn\")\nRel(from, to, \"label\")\nLAYOUT_WITH_LEGEND()", + "ai-prompt": "Рисуй C4 Component diagram. В блоке ```uml-c4component только макросы C4 Component. Без @startuml и !include.", + "includes": [ + "https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml" + ], + "prefix": "@startuml\n", + "postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml" + }, + "usecase": { + "title": "Прецеденты (Use Case)", + "version": "1.0.0", + "author": "md2gost", + "docs": "left to right direction\nactor \"Имя\" as A\nrectangle Система {\n usecase \"Сценарий\" as UC1\n}\nA --> UC1\nUC1 .> UC2 : <>\nUC3 .> UC1 : <>", + "ai-prompt": "Рисуй UML Use Case. В блоке ```uml-usecase (или ```usecase) — actor, usecase, связи. Без @startuml — схема обернёт сама.", + "includes": [], + "prefix": "@startuml\nleft to right direction\nskinparam actorStyle awesome\n", + "postfix": "\n@enduml" + }, + "bpmn": { + "title": "BPMN 2.0", + "version": "1.0.0", + "author": "md2gost", + "docs": "Pool(alias, \"Участник\") { Lane(alias, \"Роль\") { … } }\nStart / StartMessage / StartTimer / StartSignal\nCatchMessage / ThrowMessage / CatchTimer / CatchError\nEnd / EndMessage / EndError / EndTerminate\nTask(alias, \"Имя\") / UserTask / ServiceTask / ScriptTask / ManualTask / SendTask / ReceiveTask / BusinessRuleTask\nSubProcess / SubProcessExpanded { … } / CallActivity\nXOR / AND / OR / EventBased / ComplexGW\nFlow / Flow_R/L/U/D / CondFlow(from, to, \"условие\") / DefaultFlow / MessageFlow\nDataObject / DataStore / Annotation\nBoundaryError + Attach(task, event)\nBPMN_VERTICAL() / BPMN_LEGEND()", + "ai-prompt": "Рисуй BPMN 2.0 (OMG). В блоке ```uml-bpmn (или ```bpmn) только макросы библиотеки md2gost. Без @startuml и без !include.\n\nПравила: Sequence Flow (Flow) только внутри одного пула. Между пулами — MessageFlow. У процесса есть Start и хотя бы один End. Развилка и слияние — шлюзы (XOR/AND/OR), не рисуй ветвление со стрелок задачи без шлюза, если это решение. Подпись условия — на CondFlow/Flow от шлюза. DefaultFlow — ветка «иначе». Не используй activity-syntax (start/:task/;), mxgraph и чистый UML.\n\nМакросы:\nПул: Pool(alias, \"Имя\") { Lane(alias, \"Роль\") { ... } }\nСобытия: Start, StartMessage, StartTimer, StartSignal, StartCondition, Intermediate, CatchMessage, ThrowMessage, CatchTimer, CatchSignal, ThrowSignal, CatchError, End, EndMessage, EndError, EndTerminate, EndSignal\nЗадачи: Task, UserTask, ServiceTask, ScriptTask, ManualTask, SendTask, ReceiveTask, BusinessRuleTask, SubProcess, SubProcessExpanded { ... }, CallActivity\nШлюзы: Exclusive/XOR, Parallel/AND, Inclusive/OR, EventBased, ComplexGW\nПотоки: Flow, Flow_R, Flow_L, Flow_U, Flow_D, CondFlow(from,to,\"условие\"), DefaultFlow, MessageFlow, Assoc, DataAssoc\nДанные: DataObject, DataStore, Annotation\nГраница: BoundaryError/Timer/Message + Attach(task, event)\nМакет: Lay_R/L/U/D, BPMN_VERTICAL(), BPMN_LEGEND()\nПсевдонимы элементов — латиница (s, t1, gw, e_ok). Не называй alias зарезервированными словами end/start/group.", + "includes": [ + "BPMN.puml" + ], + "prefix": "@startuml\nleft to right direction\n", + "postfix": "\n@enduml" + } +} diff --git a/md2gost/dnd.py b/md2gost/dnd.py new file mode 100644 index 0000000..7ce61cb --- /dev/null +++ b/md2gost/dnd.py @@ -0,0 +1,326 @@ +"""File-drop helpers for the md2gost GUI (tkinterdnd2 or Windows WM_DROPFILES).""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Callable +from urllib.parse import unquote, urlparse + +DropCallback = Callable[[list[str]], None] + + +def parse_tkdnd_files(data: str) -> list[str]: + """Parse a TkDND file list (`{C:\\a b.md} C:\\c.md`).""" + files: list[str] = [] + text = (data or "").strip() + i = 0 + n = len(text) + while i < n: + if text[i].isspace(): + i += 1 + continue + if text[i] == "{": + j = text.find("}", i + 1) + if j < 0: + files.append(text[i + 1 :]) + break + files.append(text[i + 1 : j]) + i = j + 1 + continue + j = i + while j < n and not text[j].isspace(): + j += 1 + files.append(text[i:j]) + i = j + return files + + +def normalize_drop_paths(items: list[str] | str) -> list[str]: + if isinstance(items, str): + items = parse_tkdnd_files(items) + out: list[str] = [] + for raw in items: + if isinstance(raw, bytes): + try: + raw = raw.decode("utf-8") + except UnicodeDecodeError: + raw = raw.decode(sys.getfilesystemencoding() or "utf-8", errors="replace") + path = raw.strip().strip('"') + if not path: + continue + if path.lower().startswith("file:"): + parsed = urlparse(path) + path = unquote(parsed.path) + if sys.platform == "win32" and path.startswith("/") and len(path) > 3 and path[2] == ":": + path = path[1:] + out.append(os.path.normpath(path)) + return out + + +def first_markdown(paths: list[str]) -> str | None: + for path in paths: + if path.lower().endswith(".md") and os.path.isfile(path): + return path + return None + + +def enable_file_drop(widget, callback: DropCallback) -> str: + """Enable dropping files onto widget. Returns backend name: tkdnd | win32 | none.""" + def _deliver(items: list[str] | str) -> None: + callback(normalize_drop_paths(items)) + + if _try_tkdnd(widget, _deliver): + return "tkdnd" + if sys.platform == "win32" and _WinDropHook.attach(widget, _deliver): + return "win32" + return "none" + + +def _try_tkdnd(widget, deliver: DropCallback) -> bool: + try: + from tkinterdnd2 import DND_FILES + except ImportError: + return False + register = getattr(widget, "drop_target_register", None) + bind = getattr(widget, "dnd_bind", None) + if register is None or bind is None: + root = widget.winfo_toplevel() + register = getattr(root, "drop_target_register", None) + bind = getattr(root, "dnd_bind", None) + target = root + else: + target = widget + if register is None or bind is None: + return False + try: + register(DND_FILES) + bind("<>", lambda event: deliver(getattr(event, "data", "") or "")) + bind("<>", lambda event: widget.event_generate("<>")) + bind("<>", lambda event: widget.event_generate("<>")) + except Exception: + return False + return True + + +class _WinDropHook: + """Subclass a Win32 HWND and accept WM_DROPFILES. Keep a strong ref on the widget.""" + + _hooks: list[_WinDropHook] = [] + + def __init__(self, widget, callback: DropCallback): + self.widget = widget + self.callback = callback + self._pending: list[str] = [] + self._old_proc = None + self._wndproc = None + self._hwnd = 0 + self._alive = True + + @classmethod + def attach(cls, widget, callback: DropCallback) -> bool: + try: + toplevel = widget.winfo_toplevel() + except Exception: + toplevel = widget + if getattr(toplevel, "_md2gost_dnd_hooked", False): + return True + + hook = cls(widget, callback) + + def start(_event=None): + if getattr(toplevel, "_md2gost_dnd_hooked", False): + return + if hook._install(): + toplevel._md2gost_dnd_hooked = True + toplevel._md2gost_dnd_hook = hook + cls._hooks.append(hook) + + widget.bind("", start, add="+") + widget.bind("", lambda e: hook._detach(), add="+") + try: + if widget.winfo_ismapped(): + start() + else: + widget.after_idle(start) + except Exception: + widget.after(200, start) + return True + + def _install(self) -> bool: + try: + api = _win32_drop_api() + except Exception: + return False + + hwnd = _toplevel_hwnd(self.widget) + if not hwnd: + return False + + WM_DROPFILES = 0x0233 + GWLP_WNDPROC = -4 + WS_EX_ACCEPTFILES = 0x00000010 + GWL_EXSTYLE = -20 + + def wndproc(hw, msg, wp, lp): + if msg == WM_DROPFILES: + try: + hdrop = int(wp) if wp is not None else 0 + if hdrop: + self._pending.extend(_query_drop_files(hdrop, api=api)) + api.DragFinish(hdrop) + except Exception: + pass + return 0 + if self._old_proc: + return api.CallWindowProc(self._old_proc, hw, msg, wp, lp) + return api.DefWindowProc(hw, msg, wp, lp) + + self._wndproc = api.WNDPROC(wndproc) + try: + ex = api.GetWindowLong(hwnd, GWL_EXSTYLE) or 0 + api.SetWindowLong(hwnd, GWL_EXSTYLE, int(ex) | WS_EX_ACCEPTFILES) + api.DragAcceptFiles(hwnd, True) + self._old_proc = api.GetWindowLongPtr(hwnd, GWLP_WNDPROC) + api.SetWindowLongPtr(hwnd, GWLP_WNDPROC, api.as_ptr(self._wndproc)) + except Exception: + return False + + self._hwnd = hwnd + self._poll() + return True + + def _poll(self) -> None: + if not self._alive: + return + if self._pending: + files = self._pending[:] + self._pending.clear() + try: + self.callback(files) + except Exception: + pass + try: + self.widget.after(120, self._poll) + except Exception: + self._alive = False + + def _detach(self) -> None: + self._alive = False + if not self._hwnd or self._old_proc is None: + return + try: + api = _win32_drop_api() + api.SetWindowLongPtr(self._hwnd, -4, self._old_proc) + except Exception: + pass + self._old_proc = None + + +class _Win32DropApi: + def __init__(self): + import ctypes + from ctypes import wintypes + + self._ctypes = ctypes + is64 = ctypes.sizeof(ctypes.c_void_p) == 8 + # wintypes.WPARAM/LPARAM historically were 32-bit; force pointer width. + WPARAM = ctypes.c_uint64 if is64 else ctypes.c_uint + LPARAM = ctypes.c_int64 if is64 else ctypes.c_long + HWND = ctypes.c_void_p + LRESULT = ctypes.c_int64 if is64 else ctypes.c_long + + user32 = ctypes.WinDLL("user32", use_last_error=True) + shell32 = ctypes.WinDLL("shell32", use_last_error=True) + + get_ptr = user32.GetWindowLongPtrW if is64 else user32.GetWindowLongW + set_ptr = user32.SetWindowLongPtrW if is64 else user32.SetWindowLongW + get_ptr.argtypes = [HWND, ctypes.c_int] + get_ptr.restype = ctypes.c_void_p + set_ptr.argtypes = [HWND, ctypes.c_int, ctypes.c_void_p] + set_ptr.restype = ctypes.c_void_p + + # GWL_EXSTYLE is a 32-bit style mask; Get/SetWindowLongW is enough. + get_long = user32.GetWindowLongW + set_long = user32.SetWindowLongW + get_long.argtypes = [HWND, ctypes.c_int] + get_long.restype = ctypes.c_long + set_long.argtypes = [HWND, ctypes.c_int, ctypes.c_long] + set_long.restype = ctypes.c_long + + call_proc = user32.CallWindowProcW + call_proc.argtypes = [ctypes.c_void_p, HWND, wintypes.UINT, WPARAM, LPARAM] + call_proc.restype = LRESULT + + def_proc = user32.DefWindowProcW + def_proc.argtypes = [HWND, wintypes.UINT, WPARAM, LPARAM] + def_proc.restype = LRESULT + + accept = shell32.DragAcceptFiles + accept.argtypes = [HWND, wintypes.BOOL] + accept.restype = None + + finish = shell32.DragFinish + finish.argtypes = [ctypes.c_void_p] + finish.restype = None + + query = shell32.DragQueryFileW + query.argtypes = [ctypes.c_void_p, wintypes.UINT, ctypes.c_wchar_p, wintypes.UINT] + query.restype = wintypes.UINT + + self.shell32 = shell32 + self.WNDPROC = ctypes.WINFUNCTYPE(LRESULT, HWND, wintypes.UINT, WPARAM, LPARAM) + self.GetWindowLongPtr = get_ptr + self.SetWindowLongPtr = set_ptr + self.GetWindowLong = get_long + self.SetWindowLong = set_long + self.CallWindowProc = call_proc + self.DefWindowProc = def_proc + self.DragAcceptFiles = accept + self.DragFinish = finish + self.DragQueryFileW = query + + def as_ptr(self, wndproc): + return self._ctypes.cast(wndproc, self._ctypes.c_void_p).value + + +_WIN32_DROP_API = None + + +def _win32_drop_api() -> _Win32DropApi: + global _WIN32_DROP_API + if _WIN32_DROP_API is None: + _WIN32_DROP_API = _Win32DropApi() + return _WIN32_DROP_API + + +def _toplevel_hwnd(widget) -> int: + try: + import ctypes + hwnd = int(widget.winfo_id()) + GA_ROOT = 2 + root = ctypes.windll.user32.GetAncestor(hwnd, GA_ROOT) + if root: + return int(root) + parent = ctypes.windll.user32.GetParent(hwnd) + return int(parent or hwnd) + except Exception: + try: + return int(widget.winfo_id()) + except Exception: + return 0 + + +def _query_drop_files(hdrop: int, api: _Win32DropApi | None = None) -> list[str]: + import ctypes + + query = (api or _win32_drop_api()).DragQueryFileW + count = query(hdrop, 0xFFFFFFFF, None, 0) + files: list[str] = [] + for i in range(count): + length = query(hdrop, i, None, 0) + buf = ctypes.create_unicode_buffer(length + 1) + query(hdrop, i, buf, length + 1) + if buf.value: + files.append(buf.value) + return files diff --git a/md2gost/docx_elements.py b/md2gost/docx_elements.py index f580403..4751610 100644 --- a/md2gost/docx_elements.py +++ b/md2gost/docx_elements.py @@ -24,6 +24,7 @@ __all__ = [ "create_table_row", "create_table_cell", "apply_cell_merge", + "set_table_box_borders", ] @@ -117,3 +118,27 @@ def apply_cell_merge(cell: _Cell, v_merge: str | None = None, grid_span: int | N tcPr.append(create_element("w:vMerge")) if grid_span and grid_span > 1: tcPr.append(create_element("w:gridSpan", {"w:val": str(grid_span)})) + + +def set_table_box_borders(table: Table, *, sz: str = "4", color: str = "000000") -> None: + """Outer frame only — no inside H/V lines (listings look like text in a box).""" + tbl = table._tbl + tblPr = tbl.tblPr + if tblPr is None: + tblPr = create_element("w:tblPr") + tbl.insert(0, tblPr) + for el in list(tblPr.findall(qn("w:tblBorders"))): + tblPr.remove(el) + solid = {"w:val": "single", "w:sz": sz, "w:space": "0", "w:color": color} + none = {"w:val": "nil"} + borders = create_element("w:tblBorders") + for edge, attrs in ( + ("w:top", solid), + ("w:left", solid), + ("w:bottom", solid), + ("w:right", solid), + ("w:insideH", none), + ("w:insideV", none), + ): + borders.append(create_element(edge, attrs)) + tblPr.append(borders) \ No newline at end of file diff --git a/md2gost/docx_svg.py b/md2gost/docx_svg.py new file mode 100644 index 0000000..864940e --- /dev/null +++ b/md2gost/docx_svg.py @@ -0,0 +1,55 @@ +"""Attach SVG as Word svgBlip alongside a PNG InlineShape (Office 2016+).""" + +from __future__ import annotations + +from pathlib import Path + +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.opc.part import Part +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from lxml import etree + +SVG_CONTENT_TYPE = "image/svg+xml" +SVG_BLIP_URI = "{96DAC541-7B7A-43C6-8E14-B03AE682B59D}" +ASVG_NS = "http://schemas.microsoft.com/office/drawing/2016/SVG/main" +ASVG_SVG_BLIP = f"{{{ASVG_NS}}}svgBlip" + + +def attach_svg_blip(run, inline_shape, svg_path: str | Path) -> bool: + """ + After run.add_picture(png), add an SVG part and asvg:svgBlip on the PNG blip. + Word 2016+ uses the vector; older apps keep the PNG. + Returns True if the SVG was attached. + """ + path = Path(svg_path) + if not path.is_file() or path.stat().st_size <= 0: + return False + data = path.read_bytes() + head = data.lstrip()[:200].lower() + if not (head.startswith(b" tuple[str | None, bool, bool]: + """Return (clean_text, with_listing, landscape) from caption / image-title tail.""" + text = (raw or "").strip() + with_listing = bool(_LISTING_FLAG_RE.search(text)) + landscape = bool(_LANDSCAPE_FLAG_RE.search(text)) + if with_listing: + text = _LISTING_FLAG_RE.sub(" ", text) + if landscape: + text = _LANDSCAPE_FLAG_RE.sub(" ", text) + text = " ".join(text.split()).strip() + return (text or None), with_listing, landscape class Caption(BlockElement): """Represents caption element - Syntax: %label Caption text [+listing] + Syntax: %label Caption text [+listing] [+landscape] """ + # Interrupt an open paragraph so «текст:\\n%id …» still becomes a Caption + # (otherwise marko treats the % line as paragraph continuation / plain text). + breaks_paragraph = True + priority = 6 pattern = r"\%(\w+)( (.+))?" def __init__(self, match: Match[str]): self.unique_name = match.group(1) raw = (match.group(3) or "").strip() - self.with_listing = bool(_LISTING_FLAG_RE.search(raw)) - if self.with_listing: - raw = _LISTING_FLAG_RE.sub(" ", raw).strip() - self.text = raw or None + self.text, self.with_listing, self.landscape = strip_caption_flags(raw) @classmethod def match(cls, source: Source) -> Match[str] | None: diff --git a/md2gost/extended_markdown/image.py b/md2gost/extended_markdown/image.py index fe0280e..2450318 100644 --- a/md2gost/extended_markdown/image.py +++ b/md2gost/extended_markdown/image.py @@ -2,6 +2,8 @@ import re from marko.inline import Image as Image_ +from .caption import strip_caption_flags + class Image(Image_): override = True @@ -9,7 +11,11 @@ class Image(Image_): super().__init__(match) self.unique_name = None + self.with_listing = False + self.landscape = False if self.title and (m := re.match(r"\%(\w+)( (.+))?", self.title)): self.unique_name = m.group(1) - self.title = (m.group(3) or "").strip() or None + self.title, self.with_listing, self.landscape = strip_caption_flags( + (m.group(3) or "").strip() + ) diff --git a/md2gost/gui.py b/md2gost/gui.py new file mode 100644 index 0000000..7504176 --- /dev/null +++ b/md2gost/gui.py @@ -0,0 +1,1260 @@ +"""Tkinter GUI for md2gost: drop a .md, pick options, convert.""" + +from __future__ import annotations + +import os +import sys +import threading +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +from .diagram_includes import cache_entry_count, reset_include_cache +from .diagram_renderer import diagram_engine_status +from .diagram_schemes import ( + DiagramScheme, + SCHEME_ID_RE, + ensure_user_schemes, + load_schemes_from_path, + reload_schemes, + save_schemes, + user_schemes_path, +) +from .dnd import enable_file_drop, first_markdown +from .help_content import SCHEMES_HELP, USAGE_HELP, load_prompt_catalog +from .pipeline import ConvertRequest, convert, default_output_path +from .profiles import ( + DEFAULT_HEADING_NUMBERING, + DEFAULT_TABLE_CONTINUATION, + DEFAULT_LISTING_CONTINUATION, + DEFAULT_TOC_MODE, + DOC_TYPES, + HEADING_NUMBERING_MODES, + TABLE_CONTINUATION_MODES, + LISTING_CONTINUATION_MODES, + TOC_MODES, +) + +DOC_TYPE_LABELS = { + "coursework": "Курсовая (ГОСТ / МИРЭА)", + "practice": "Практика", + "vkr": "ВКР", + "PIS_custom": "Отчёт по практическим (ПИС)", + "APID_coursework": "Курсовая АПИД", +} +HEADING_LABELS = { + "manual": "Из markdown (цифры уже в заголовках)", + "auto": "Автонумерация Word", +} +TOC_LABELS = { + "native": "Поле Word (обновить при открытии)", + "manual": "Собрать в md2gost", +} +TABLE_LABELS = { + "off": "Не резать", + "soft": "Как «не резать»", + "word": "Через Word после сборки (по умолчанию)", + "legacy": "Резать + «Продолжение»", + "caption": "Резать + разрыв страницы + «Продолжение»", +} +FALLBACK_LABELS = { + "remote": "Авто: локально, иначе интернет (kroki.io)", + "local": "Только этот компьютер (нужна Java)", + "off": "Не рисовать диаграммы", +} +FORMAT_LABELS = { + "png": "PNG (растр, совместимо везде)", + "svg": "SVG + PNG (вектор в Word 2016+)", +} + +NAVY = "#1e3a5f" +NAVY_DIM = "#4a6583" +PAPER = "#f6f3ec" +ZONE = "#e8eef5" +ZONE_OK = "#dceee3" +INK = "#243044" +MUTED = "#5c6b7a" +TIP_BG = "#ffffe1" + + +def _make_root() -> tuple[tk.Tk, str]: + try: + from tkinterdnd2 import TkinterDnD + return TkinterDnD.Tk(), "tkdnd" + except ImportError: + return tk.Tk(), "tk" + + +def _combo_values(mapping: dict[str, str], keys: tuple[str, ...]) -> list[str]: + return [f"{key} — {mapping.get(key, key)}" for key in keys] + + +def _combo_key(display: str, allowed: tuple[str, ...], default: str) -> str: + key = (display or "").split(" — ", 1)[0].strip() + return key if key in allowed else default + + +def _display(mapping: dict[str, str], key: str) -> str: + return f"{key} — {mapping.get(key, key)}" + + +class _Tooltip: + """Simple balloon tip on hover (Tk has no built-in tooltip).""" + + def __init__(self, widget, text: str, delay_ms: int = 400): + self.widget = widget + self.text = text + self.delay_ms = delay_ms + self._after_id: str | None = None + self._tip: tk.Toplevel | None = None + widget.bind("", self._on_enter, add="+") + widget.bind("", self._on_leave, add="+") + widget.bind("", self._on_leave, add="+") + + def _on_enter(self, _event=None) -> None: + self._cancel() + self._after_id = self.widget.after(self.delay_ms, self._show) + + def _on_leave(self, _event=None) -> None: + self._cancel() + self._hide() + + def _cancel(self) -> None: + if self._after_id is not None: + try: + self.widget.after_cancel(self._after_id) + except tk.TclError: + pass + self._after_id = None + + def _show(self) -> None: + self._after_id = None + if self._tip is not None or not self.text: + return + try: + x = self.widget.winfo_rootx() + 16 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 + except tk.TclError: + return + tip = tk.Toplevel(self.widget) + tip.wm_overrideredirect(True) + tip.wm_geometry(f"+{x}+{y}") + try: + tip.attributes("-topmost", True) + except tk.TclError: + pass + label = tk.Label( + tip, + text=self.text, + justify=tk.LEFT, + background=TIP_BG, + foreground=INK, + relief=tk.SOLID, + borderwidth=1, + padx=6, + pady=4, + font=("Segoe UI", 9) if os.name == "nt" else ("Helvetica", 9), + wraplength=320, + ) + label.pack() + self._tip = tip + + def _hide(self) -> None: + if self._tip is not None: + try: + self._tip.destroy() + except tk.TclError: + pass + self._tip = None + + +def _tip(widget, text: str) -> None: + if text: + _Tooltip(widget, text) + + +class Md2GostApp: + def __init__(self, initial: ConvertRequest | None = None): + self.req = initial or ConvertRequest() + self.root, _ = _make_root() + self.root.title("md2gost — Markdown → DOCX") + self.root.minsize(680, 560) + self.root.geometry("780x700") + self.root.configure(bg=PAPER) + + self._busy = False + self._auto_output = True + self._dnd_backend = "none" + self._last_md = "" + self._schemes: dict[str, DiagramScheme] = {} + self._scheme_ids: list[str] = [] + self._scheme_selecting = False + self._debug_next = False + + self._win_files: tk.Toplevel | None = None + self._win_diagrams: tk.Toplevel | None = None + self._win_schemes: tk.Toplevel | None = None + self._win_help: dict[str, tk.Toplevel] = {} + + ensure_user_schemes() + self._build_style() + self._init_vars() + self._build_menu() + self._build() + self._apply_request(self.req) + self.root.after(300, self._setup_dnd) + + if self.req.filename: + self._set_markdown(self.req.filename, from_user_output=bool(self.req.output)) + + def _build_style(self) -> None: + style = ttk.Style(self.root) + try: + style.theme_use("vista" if os.name == "nt" else "clam") + except tk.TclError: + pass + font = ("Segoe UI", 10) if os.name == "nt" else ("Helvetica", 10) + font_title = ("Segoe UI", 16, "bold") if os.name == "nt" else ("Helvetica", 16, "bold") + style.configure(".", background=PAPER, foreground=INK, font=font) + style.configure("TFrame", background=PAPER) + style.configure("TLabelframe", background=PAPER, foreground=NAVY) + style.configure("TLabelframe.Label", background=PAPER, foreground=NAVY, font=font) + style.configure("TLabel", background=PAPER, foreground=INK, font=font) + style.configure("Title.TLabel", font=font_title, foreground=NAVY, background=PAPER) + style.configure("Hint.TLabel", foreground=MUTED, background=PAPER) + style.configure("TCheckbutton", background=PAPER, font=font) + style.configure("TButton", font=font, padding=(10, 4)) + style.configure("TEntry", padding=3) + style.configure("TCombobox", padding=3) + + def _init_vars(self) -> None: + self.output_var = tk.StringVar() + self.type_var = tk.StringVar() + self.heading_var = tk.StringVar() + self.toc_var = tk.StringVar() + self.table_var = tk.StringVar() + self.listing_var = tk.StringVar() + self.emdash_var = tk.BooleanVar(value=False) + self.hr_pagebreak_var = tk.BooleanVar(value=False) + self.open_var = tk.BooleanVar(value=True) + self.check_var = tk.BooleanVar(value=False) + self.check_only_var = tk.BooleanVar(value=False) + self.strict_var = tk.BooleanVar(value=False) + self.check_pages_var = tk.BooleanVar(value=False) + self.table_repeat_header_var = tk.BooleanVar(value=False) + self.template_var = tk.StringVar() + self.title_var = tk.StringVar() + self.assignment_var = tk.StringVar() + self.fallback_var = tk.StringVar() + self.format_var = tk.StringVar() + self.plantuml_var = tk.StringVar() + self.kroki_var = tk.StringVar() + self.scale_var = tk.StringVar(value="2") + self.highlight_var = tk.BooleanVar(value=False) + self.debug_var = tk.BooleanVar(value=False) + self.output_var.trace_add("write", self._on_output_edited) + + def _build_menu(self) -> None: + # tearoff=0 обязателен: иначе index 0 — пунктир, и «Дебаг» перезаписывает «Справку» + menubar = tk.Menu(self.root, tearoff=0) + + settings = tk.Menu(menubar, tearoff=0) + settings.add_command(label="Файлы…", command=self._open_files_window) + settings.add_command(label="Диаграммы…", command=self._open_diagrams_window) + menubar.add_cascade(label="Настройки", menu=settings) + + menubar.add_command(label="Шаблоны UML", command=self._open_schemes_window) + + help_menu = tk.Menu(menubar, tearoff=0) + help_menu.add_command(label="Инструкция", command=lambda: self._open_help_window("usage")) + help_menu.add_command(label="Схемы", command=lambda: self._open_help_window("schemes")) + help_menu.add_command(label="Промпт для ИИ", command=lambda: self._open_help_window("prompts")) + menubar.add_cascade(label="Справка", menu=help_menu) + + menubar.add_command(label="Дебаг", command=self._toggle_debug) + self._debug_menu_index = int(menubar.index("end")) + self._menubar = menubar + self.root.config(menu=menubar) + + def _raise_or_create(self, existing: tk.Toplevel | None, factory) -> tk.Toplevel: + if existing is not None: + try: + if existing.winfo_exists(): + existing.deiconify() + existing.lift() + existing.focus_force() + return existing + except tk.TclError: + pass + return factory() + + def _open_files_window(self) -> None: + self._win_files = self._raise_or_create(self._win_files, self._create_files_window) + + def _open_diagrams_window(self) -> None: + self._win_diagrams = self._raise_or_create(self._win_diagrams, self._create_diagrams_window) + + def _open_schemes_window(self) -> None: + self._win_schemes = self._raise_or_create(self._win_schemes, self._create_schemes_window) + + def _open_help_window(self, kind: str) -> None: + win = self._win_help.get(kind) + if win is not None: + try: + if win.winfo_exists(): + win.deiconify() + win.lift() + win.focus_force() + return + except tk.TclError: + pass + self._win_help[kind] = self._create_help_window(kind) + + def _create_files_window(self) -> tk.Toplevel: + win = tk.Toplevel(self.root) + win.title("Настройки — Файлы") + win.configure(bg=PAPER) + win.minsize(520, 200) + win.geometry("620x240") + win.transient(self.root) + frame = ttk.Frame(win, padding=12) + frame.pack(fill=tk.BOTH, expand=True) + frame.columnconfigure(1, weight=1) + self._row_path(frame, 0, "Шаблон DOCX", self.template_var, self._browse_template, clearable=True, + tip="Пусто — встроенный Template.docx") + self._row_path(frame, 1, "Титульный лист", self.title_var, self._browse_title, clearable=True, + tip="DOCX титула, вставляется перед телом отчёта") + self._row_path(frame, 2, "Бланк задания", self.assignment_var, self._browse_assignment, clearable=True, + tip="DOCX бланка задания, вставляется перед телом отчёта") + hint = ttk.Label( + frame, + text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.", + style="Hint.TLabel", + wraplength=560, + ) + hint.grid(row=3, column=0, columnspan=2, sticky="w", pady=(8, 0)) + win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_files")) + return win + + def _create_diagrams_window(self) -> tk.Toplevel: + win = tk.Toplevel(self.root) + win.title("Настройки — Диаграммы") + win.configure(bg=PAPER) + win.minsize(560, 420) + win.geometry("680x520") + win.transient(self.root) + extra = ttk.Frame(win, padding=12) + extra.pack(fill=tk.BOTH, expand=True) + extra.columnconfigure(1, weight=1) + + hint = ttk.Label( + extra, + text=( + "Блоки ```uml / ```uml-c4 / ```bpmn / ```mermaid сами станут рисунками.\n" + "Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n" + "UML: сначала PlantUML + Java, иначе интернет (kroki.io). Mermaid — только через Kroki." + ), + style="Hint.TLabel", + wraplength=640, + justify=tk.LEFT, + ) + hint.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8)) + + status_row = ttk.Frame(extra) + status_row.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + status_row.columnconfigure(0, weight=1) + self.diag_status = ttk.Label(status_row, text="", style="Hint.TLabel", wraplength=480, justify=tk.LEFT) + self.diag_status.grid(row=0, column=0, sticky="w") + btn_dl = ttk.Button(status_row, text="Скачать PlantUML", command=self._download_plantuml) + btn_dl.grid(row=0, column=1, sticky="e", padx=(8, 0)) + _tip(btn_dl, "Скачать plantuml.jar для локальной отрисовки (нужна Java)") + + self._combo( + extra, 2, "Как рисовать", + self.fallback_var, + _combo_values(FALLBACK_LABELS, ("remote", "local", "off")), + tip="Локально (PlantUML+Java), через интернет или не рисовать", + ) + self._combo( + extra, 3, "Формат схем", + self.format_var, + _combo_values(FORMAT_LABELS, ("png", "svg")), + tip="PNG везде; SVG — вектор в Word 2016+ (PNG рядом как запасной)", + ) + + ttk.Label(extra, text="Scale PNG (PlantUML)").grid(row=4, column=0, sticky="w", padx=(0, 8), pady=3) + scale_row = ttk.Frame(extra) + scale_row.grid(row=4, column=1, sticky="w", pady=3) + scale_entry = ttk.Entry(scale_row, textvariable=self.scale_var, width=8) + scale_entry.pack(side=tk.LEFT) + _tip(scale_entry, "Качество растра PlantUML; на странице размер как при 1") + ttk.Label( + scale_row, + text=" качество; на странице размер как при 1", + style="Hint.TLabel", + ).pack(side=tk.LEFT) + + ttk.Label( + extra, + text="SVG — вектор в Word 2016+ (PNG всегда рядом как запасной). LibreOffice покажет растр.\n" + "+landscape у %подписи — альбомная A4 под широкий рисунок/таблицу.", + style="Hint.TLabel", + wraplength=640, + ).grid(row=5, column=0, columnspan=2, sticky="w", pady=(2, 0)) + ttk.Label( + extra, + text="Свой plantuml.jar — только если хотите другой файл. Пусто = вшитый или скачаем сами.", + style="Hint.TLabel", + wraplength=640, + ).grid(row=6, column=0, columnspan=2, sticky="w", pady=(6, 0)) + self._row_path(extra, 7, "Свой plantuml.jar", self.plantuml_var, self._browse_jar, clearable=True, + tip="Пусто — вшитый jar или скачанный автоматически") + ttk.Label( + extra, + text="Свой Kroki — только если у вас крутится свой сервер. Пусто = сначала localhost:8000, потом kroki.io.", + style="Hint.TLabel", + wraplength=640, + ).grid(row=8, column=0, columnspan=2, sticky="w", pady=(6, 0)) + ttk.Label(extra, text="Свой Kroki URL").grid(row=9, column=0, sticky="w", padx=(0, 8), pady=3) + kroki_entry = ttk.Entry(extra, textvariable=self.kroki_var) + kroki_entry.grid(row=9, column=1, sticky="ew", pady=3) + _tip(kroki_entry, "URL своего Kroki; пусто — localhost:8000, затем kroki.io") + + extra_flags = ttk.Frame(extra) + extra_flags.grid(row=10, column=0, columnspan=2, sticky="w", pady=(8, 0)) + cb_hl = ttk.Checkbutton(extra_flags, text="Подсветка синтаксиса в листингах", variable=self.highlight_var) + cb_hl.pack(side=tk.LEFT, padx=(0, 16)) + _tip(cb_hl, "Цветная подсветка кода в листингах DOCX") + + cache_row = ttk.Frame(extra) + cache_row.grid(row=11, column=0, columnspan=2, sticky="w", pady=(10, 0)) + btn_open = ttk.Button(cache_row, text="Открыть файл схем", command=self._open_schemes_file) + btn_open.pack(side=tk.LEFT, padx=(0, 8)) + _tip(btn_open, "Открыть md2gost.schemes.json во внешней программе") + btn_cache = ttk.Button(cache_row, text="Сбросить кэш includes", command=self._reset_include_cache) + btn_cache.pack(side=tk.LEFT) + _tip(btn_cache, "Удалить скачанные !include файлы; при следующем рендере скачаются снова") + + self._refresh_diagram_status() + win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_diagrams")) + return win + + def _create_schemes_window(self) -> tk.Toplevel: + win = tk.Toplevel(self.root) + win.title("Шаблоны UML") + win.configure(bg=PAPER) + win.minsize(700, 480) + win.geometry("820x560") + win.transient(self.root) + parent = ttk.Frame(win, padding=12) + parent.pack(fill=tk.BOTH, expand=True) + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + self._build_schemes_tab(parent) + win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_schemes")) + return win + + def _create_help_window(self, kind: str) -> tk.Toplevel: + titles = { + "usage": "Справка — Инструкция", + "schemes": "Справка — Схемы", + "prompts": "Справка — Промпт для ИИ", + } + win = tk.Toplevel(self.root) + win.title(titles.get(kind, "Справка")) + win.configure(bg=PAPER) + win.minsize(560, 400) + win.geometry("700x560") + win.transient(self.root) + frame = ttk.Frame(win, padding=12) + frame.pack(fill=tk.BOTH, expand=True) + if kind == "usage": + self._build_help(frame) + elif kind == "schemes": + self._build_schemes_help(frame) + else: + self._build_prompts(frame) + + def on_close(): + self._win_help.pop(kind, None) + try: + win.destroy() + except tk.TclError: + pass + + win.protocol("WM_DELETE_WINDOW", on_close) + return win + + def _close_toplevel(self, win: tk.Toplevel, attr: str) -> None: + setattr(self, attr, None) + try: + win.destroy() + except tk.TclError: + pass + + def _toggle_debug(self) -> None: + if self.debug_var.get(): + self.debug_var.set(False) + self._debug_next = False + self._sync_debug_menu() + self._append_log("Дебаг выключен.") + return + self.debug_var.set(True) + self._debug_next = True + self._sync_debug_menu() + messagebox.showinfo( + "md2gost", + "Следующая сборка будет с отладкой.\n" + "В документ добавятся отладочные данные.\n" + "После этой конвертации режим сбросится.", + ) + self._append_log("Дебаг: следующая конвертация с отладочными данными.") + + def _sync_debug_menu(self) -> None: + label = "✓ Дебаг" if self.debug_var.get() else "Дебаг" + try: + idx = self._debug_menu_index + if self._menubar.type(idx) != "command": + return + self._menubar.entryconfigure(idx, label=label) + except tk.TclError: + pass + + def _build(self) -> None: + outer = ttk.Frame(self.root, padding=12) + outer.pack(fill=tk.BOTH, expand=True) + self._build_convert(outer) + self.root.bind("", self._on_return) + + def _on_return(self, event) -> str | None: + if isinstance(event.widget, tk.Text): + return None + self._convert() + return "break" + + def _build_help(self, parent) -> None: + ttk.Label( + parent, + text="Как пользоваться и синтаксис markdown", + style="Hint.TLabel", + ).pack(anchor=tk.W, pady=(0, 6)) + box = self._readonly_text(parent, USAGE_HELP.strip() + "\n") + box.pack(fill=tk.BOTH, expand=True) + + def _build_schemes_help(self, parent) -> None: + ttk.Label( + parent, + text="Настраиваемые схемы диаграмм (uml-c4, bpmn, …)", + style="Hint.TLabel", + ).pack(anchor=tk.W, pady=(0, 6)) + box = self._readonly_text(parent, SCHEMES_HELP.strip() + "\n") + box.pack(fill=tk.BOTH, expand=True) + + def _build_prompts(self, parent) -> None: + catalog = load_prompt_catalog() + ttk.Label( + parent, + text="Скопируйте промпт целиком и вставьте в ChatGPT / Cursor / Copilot. Следующим сообщением — тип, тема, черновик.", + style="Hint.TLabel", + wraplength=640, + ).pack(anchor=tk.W, pady=(0, 8)) + + if not catalog: + ttk.Label( + parent, + text="Файлы промптов не найдены (папка prompts/ рядом с программой).", + style="Hint.TLabel", + ).pack(anchor=tk.W) + return + + self._prompt_texts = {title: text for _name, title, text in catalog} + titles = [title for _name, title, _text in catalog] + self._prompt_choice = tk.StringVar(value=titles[0]) + + row = ttk.Frame(parent) + row.pack(fill=tk.X, pady=(0, 6)) + ttk.Label(row, text="Промпт").pack(side=tk.LEFT, padx=(0, 8)) + box = ttk.Combobox(row, textvariable=self._prompt_choice, values=titles, state="readonly") + box.pack(side=tk.LEFT, fill=tk.X, expand=True) + box.bind("<>", lambda _e: self._show_selected_prompt()) + btn = ttk.Button(row, text="Скопировать", command=self._copy_selected_prompt) + btn.pack(side=tk.LEFT, padx=(8, 0)) + _tip(btn, "Скопировать выбранный промпт в буфер обмена") + + self._prompt_view = self._readonly_text(parent, self._prompt_texts[titles[0]]) + self._prompt_view.pack(fill=tk.BOTH, expand=True) + self._copy_status = ttk.Label(parent, text="", style="Hint.TLabel") + self._copy_status.pack(anchor=tk.W, pady=(6, 0)) + + def _show_selected_prompt(self) -> None: + text = self._prompt_texts.get(self._prompt_choice.get(), "") + self._prompt_view.configure(state=tk.NORMAL) + self._prompt_view.delete("1.0", tk.END) + self._prompt_view.insert("1.0", text) + self._prompt_view.configure(state=tk.DISABLED) + self._copy_status.configure(text="") + + def _copy_selected_prompt(self) -> None: + text = self._prompt_texts.get(self._prompt_choice.get(), "") + if not text: + return + self.root.clipboard_clear() + self.root.clipboard_append(text) + self.root.update_idletasks() + self._copy_status.configure(text="Скопировано в буфер обмена — можно вставлять в ИИ.") + + def _readonly_text(self, parent, content: str) -> tk.Text: + wrap = ttk.Frame(parent) + text = tk.Text( + wrap, wrap=tk.WORD, relief=tk.FLAT, + bg="#fffdf8", fg=INK, padx=10, pady=8, + font=("Consolas", 9) if os.name == "nt" else ("monospace", 9), + ) + scroll = ttk.Scrollbar(wrap, command=text.yview) + text.configure(yscrollcommand=scroll.set) + text.insert("1.0", content) + text.configure(state=tk.DISABLED) + text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scroll.pack(side=tk.RIGHT, fill=tk.Y) + text._md2gost_wrap = wrap # type: ignore[attr-defined] + + def pack(**kwargs): + wrap.pack(**kwargs) + + text.pack = pack # type: ignore[method-assign] + return text + + def _build_convert(self, outer) -> None: + self.zone = tk.Frame( + outer, bg=ZONE, highlightthickness=2, + highlightbackground=NAVY, highlightcolor=NAVY, cursor="hand2", + ) + self.zone.pack(fill=tk.X, pady=(0, 8)) + self.zone_label = tk.Label( + self.zone, + text="Перетащите .md сюда · или нажмите, чтобы выбрать файл", + bg=ZONE, fg=NAVY, font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11), + justify=tk.CENTER, pady=14, + ) + self.zone_label.pack(fill=tk.X) + self.zone_path = tk.Label( + self.zone, text="", bg=ZONE, fg=NAVY_DIM, + font=("Segoe UI", 9) if os.name == "nt" else ("Helvetica", 9), + ) + self.zone_path.pack(fill=tk.X, pady=(0, 8)) + for w in (self.zone, self.zone_label, self.zone_path): + w.bind("", lambda _e: self._browse_md()) + _tip(self.zone, "Перетащите .md или кликните, чтобы выбрать файл") + _tip(self.zone_label, "Перетащите .md или кликните, чтобы выбрать файл") + + grid = ttk.Frame(outer) + grid.pack(fill=tk.X) + grid.columnconfigure(1, weight=1) + self._row_path(grid, 0, "Выходной DOCX", self.output_var, self._browse_output, + tip="Куда сохранить документ; пусто — рядом с .md") + + main = ttk.LabelFrame(outer, text="Основные", padding=8) + main.pack(fill=tk.X, pady=(10, 8)) + main.columnconfigure(1, weight=1) + + self._combo(main, 0, "Тип документа", self.type_var, _combo_values(DOC_TYPE_LABELS, DOC_TYPES), + tip="Профиль оформления: ГОСТ МИРЭА, ПИС, АПИД…") + self._combo(main, 1, "Нумерация заголовков", self.heading_var, _combo_values(HEADING_LABELS, HEADING_NUMBERING_MODES), + tip="Цифры уже в markdown или автонумерация Word") + self._combo(main, 2, "Содержание", self.toc_var, _combo_values(TOC_LABELS, TOC_MODES), + tip="Поле Word или собранное md2gost") + self._combo(main, 3, "Продолжение таблиц", self.table_var, _combo_values(TABLE_LABELS, TABLE_CONTINUATION_MODES), + tip="off — Word сам переносит; word — Split + «Продолжение» после сборки (нужен Word)") + self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES), + tip="То же для листингов; word требует Windows + Word + pywin32") + + flags = ttk.Frame(main) + flags.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0)) + checks = [ + ("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"), + ("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"), + ("Открыть после конвертации", self.open_var, "Открыть DOCX после успешной сборки"), + ("Проверить по ТЗ", self.check_var, "Замечания по структуре, «рис.», источникам"), + ("Только проверка", self.check_only_var, "Проверить без записи DOCX"), + ("Строгий режим (код 1 при ошибках)", self.strict_var, "Ненулевой код выхода при ошибках проверки"), + ("Проверить вёрстку в Word (эвристика)", self.check_pages_var, "Полупустые страницы; нужны Word + pywin32"), + ( + "Повторять шапку таблицы при word-разрыве", + self.table_repeat_header_var, + "Только для «Продолжение таблиц = word»; по умолчанию выкл", + ), + ] + for i, (text, var, tip) in enumerate(checks): + r, c = divmod(i, 3) + cb = ttk.Checkbutton(flags, text=text, variable=var) + span = 2 if c == 0 and i >= 6 else 1 + cb.grid(row=r, column=c, columnspan=span, sticky="w", padx=(0, 16), pady=2) + _tip(cb, tip) + + actions = ttk.Frame(outer) + actions.pack(fill=tk.X, pady=(4, 8)) + self.convert_btn = tk.Button( + actions, text="Конвертировать", + command=self._convert, + bg=NAVY, fg="white", activebackground="#16304d", activeforeground="white", + disabledforeground="#d0d5dc", + font=("Segoe UI", 12, "bold") if os.name == "nt" else ("Helvetica", 12, "bold"), + relief=tk.FLAT, padx=22, pady=7, cursor="hand2", + ) + self.convert_btn.pack(side=tk.LEFT) + _tip(self.convert_btn, "Собрать DOCX из выбранного markdown") + self.progress = ttk.Progressbar(actions, mode="indeterminate", length=180) + self.progress.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True) + + log_frame = ttk.LabelFrame(outer, text="Журнал", padding=4) + log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 0)) + self.log = tk.Text( + log_frame, height=7, wrap=tk.WORD, relief=tk.FLAT, + bg="#fffdf8", fg=INK, padx=8, pady=6, + font=("Consolas", 9) if os.name == "nt" else ("monospace", 9), + ) + scroll = ttk.Scrollbar(log_frame, command=self.log.yview) + self.log.configure(yscrollcommand=scroll.set) + self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scroll.pack(side=tk.RIGHT, fill=tk.Y) + self.log.insert("1.0", "Готово к работе. Перетащите markdown-файл в верхнюю область.\n") + self.log.configure(state=tk.DISABLED) + + def _build_schemes_tab(self, parent) -> None: + ttk.Label( + parent, + text="В markdown: ```uml- или ```. Сохраняется в md2gost.schemes.json рядом с программой.", + style="Hint.TLabel", + wraplength=780, + ).grid(row=0, column=0, sticky="w", pady=(0, 6)) + + body = ttk.Frame(parent) + body.grid(row=1, column=0, sticky="nsew") + body.columnconfigure(1, weight=1) + body.rowconfigure(0, weight=1) + + left = ttk.Frame(body) + left.grid(row=0, column=0, sticky="nsw", padx=(0, 10)) + self._scheme_list = tk.Listbox(left, height=14, width=22, exportselection=False) + self._scheme_list.pack(side=tk.TOP, fill=tk.Y) + self._scheme_list.bind("<>", self._on_scheme_select) + _tip(self._scheme_list, "Список схем из md2gost.schemes.json") + btns = ttk.Frame(left) + btns.pack(fill=tk.X, pady=(6, 0)) + for text, cmd, tip in ( + ("Добавить", self._scheme_add, "Новая схема с id customN"), + ("Дублировать", self._scheme_dup, "Копия выбранной схемы"), + ("Удалить", self._scheme_del, "Удалить схему из файла пользователя"), + ("Сохранить", self._scheme_save_all, "Записать все схемы в md2gost.schemes.json"), + ("Открыть JSON", self._open_schemes_file, "Открыть файл схем во внешней программе"), + ("Сброс кэша", self._reset_include_cache, "Сбросить кэш скачанных includes"), + ): + b = ttk.Button(btns, text=text, command=cmd) + b.pack(fill=tk.X, pady=1) + _tip(b, tip) + + form = ttk.Frame(body) + form.grid(row=0, column=1, sticky="nsew") + form.columnconfigure(1, weight=1) + + self._sch_id = tk.StringVar() + self._sch_title = tk.StringVar() + self._sch_version = tk.StringVar() + self._sch_author = tk.StringVar() + row = 0 + for label, var, tip in ( + ("id", self._sch_id, "Латиница, цифры, _; начинается с буквы"), + ("title", self._sch_title, "Подпись в списке схем"), + ("version", self._sch_version, "Версия схемы"), + ("author", self._sch_author, "Автор схемы"), + ): + ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=2) + e = ttk.Entry(form, textvariable=var) + e.grid(row=row, column=1, sticky="ew", pady=2) + _tip(e, tip) + row += 1 + + for label, attr, height, tip in ( + ("docs", "_sch_docs", 4, "Шпаргалка синтаксиса / макросов"), + ("ai-prompt", "_sch_ai", 3, "Заготовка промпта для ИИ"), + ("includes", "_sch_includes", 3, "Файлы или URL .puml, по одному в строке"), + ("prefix", "_sch_prefix", 3, "Текст перед телом (часто @startuml)"), + ("postfix", "_sch_postfix", 2, "Текст после тела (часто @enduml)"), + ): + ttk.Label(form, text=label).grid(row=row, column=0, sticky="nw", padx=(0, 8), pady=2) + w = tk.Text(form, height=height, wrap=tk.WORD, font=("Consolas", 9) if os.name == "nt" else ("monospace", 9)) + w.grid(row=row, column=1, sticky="ew", pady=2) + setattr(self, attr, w) + _tip(w, tip) + row += 1 + + self._scheme_fence_hint = ttk.Label(parent, text="", style="Hint.TLabel") + self._scheme_fence_hint.grid(row=2, column=0, sticky="w", pady=(6, 0)) + self._reload_schemes_ui() + + def _text_set(self, widget: tk.Text, value: str) -> None: + widget.delete("1.0", tk.END) + if value: + widget.insert("1.0", value) + + def _text_get(self, widget: tk.Text) -> str: + return widget.get("1.0", tk.END).rstrip("\n") + + def _reload_schemes_ui(self, select_id: str | None = None) -> None: + if not hasattr(self, "_scheme_list"): + return + path = ensure_user_schemes() + self._schemes = load_schemes_from_path(path) + self._scheme_ids = sorted(self._schemes.keys()) + self._scheme_list.delete(0, tk.END) + for sid in self._scheme_ids: + title = self._schemes[sid].title or sid + self._scheme_list.insert(tk.END, title) + if not self._scheme_ids: + self._clear_scheme_form() + return + target = select_id if select_id in self._scheme_ids else self._scheme_ids[0] + idx = self._scheme_ids.index(target) + self._scheme_list.selection_clear(0, tk.END) + self._scheme_list.selection_set(idx) + self._scheme_list.see(idx) + self._fill_scheme_form(self._schemes[target]) + + def _clear_scheme_form(self) -> None: + self._scheme_selecting = True + self._sch_id.set("") + self._sch_title.set("") + self._sch_version.set("") + self._sch_author.set("") + self._text_set(self._sch_docs, "") + self._text_set(self._sch_ai, "") + self._text_set(self._sch_includes, "") + self._text_set(self._sch_prefix, "") + self._text_set(self._sch_postfix, "") + self._scheme_fence_hint.configure(text="") + self._scheme_selecting = False + + def _fill_scheme_form(self, scheme: DiagramScheme) -> None: + self._scheme_selecting = True + self._sch_id.set(scheme.id) + self._sch_title.set(scheme.title) + self._sch_version.set(scheme.version) + self._sch_author.set(scheme.author) + self._text_set(self._sch_docs, scheme.docs) + self._text_set(self._sch_ai, scheme.ai_prompt) + self._text_set(self._sch_includes, "\n".join(scheme.includes)) + self._text_set(self._sch_prefix, scheme.prefix) + self._text_set(self._sch_postfix, scheme.postfix) + self._scheme_fence_hint.configure(text=f"Оградка: ```uml-{scheme.id} или ```{scheme.id}") + self._scheme_selecting = False + + def _on_scheme_select(self, _event=None) -> None: + if self._scheme_selecting: + return + sel = self._scheme_list.curselection() + if not sel: + return + sid = self._scheme_ids[sel[0]] + self._fill_scheme_form(self._schemes[sid]) + + def _scheme_from_form(self, old_id: str | None = None) -> DiagramScheme | None: + sid = self._sch_id.get().strip().lower() + if not SCHEME_ID_RE.match(sid): + messagebox.showerror("md2gost", "id схемы: латиница, цифры, _; начинается с буквы.") + return None + includes_raw = self._text_get(self._sch_includes) + includes = [ln.strip() for ln in includes_raw.splitlines() if ln.strip()] + return DiagramScheme( + id=sid, + title=self._sch_title.get().strip() or sid, + version=self._sch_version.get().strip(), + author=self._sch_author.get().strip(), + docs=self._text_get(self._sch_docs), + ai_prompt=self._text_get(self._sch_ai), + includes=includes, + prefix=self._text_get(self._sch_prefix), + postfix=self._text_get(self._sch_postfix), + ) + + def _scheme_commit_current(self) -> str | None: + """Write form into self._schemes. Returns new id or None on error.""" + sel = self._scheme_list.curselection() + old_id = self._scheme_ids[sel[0]] if sel else None + scheme = self._scheme_from_form(old_id) + if scheme is None: + return None + if old_id and old_id != scheme.id and old_id in self._schemes: + del self._schemes[old_id] + self._schemes[scheme.id] = scheme + return scheme.id + + def _scheme_add(self) -> None: + n = 1 + while f"custom{n}" in self._schemes: + n += 1 + sid = f"custom{n}" + self._schemes[sid] = DiagramScheme( + id=sid, + title="Новая схема", + version="1.0.0", + author="", + docs="", + ai_prompt="", + includes=[], + prefix="@startuml\n", + postfix="\n@enduml", + ) + self._reload_schemes_ui(select_id=sid) + + def _scheme_dup(self) -> None: + sel = self._scheme_list.curselection() + if not sel: + return + src = self._schemes[self._scheme_ids[sel[0]]] + n = 1 + while f"{src.id}_copy{n}" in self._schemes: + n += 1 + sid = f"{src.id}_copy{n}" + self._schemes[sid] = DiagramScheme( + id=sid, + title=src.title, + version=src.version, + author=src.author, + docs=src.docs, + ai_prompt=src.ai_prompt, + includes=list(src.includes), + prefix=src.prefix, + postfix=src.postfix, + theme=src.theme, + ) + self._reload_schemes_ui(select_id=sid) + + def _scheme_del(self) -> None: + sel = self._scheme_list.curselection() + if not sel: + return + sid = self._scheme_ids[sel[0]] + if not messagebox.askyesno("md2gost", f"Удалить схему «{sid}» из файла пользователя?"): + return + del self._schemes[sid] + path = user_schemes_path() + save_schemes(self._schemes, path) + reload_schemes() + self._reload_schemes_ui() + self._append_log(f"Схема удалена: {sid}") + + def _scheme_save_all(self) -> None: + sid = self._scheme_commit_current() + if sid is None and self._scheme_ids: + return + path = user_schemes_path() + save_schemes(self._schemes, path) + reload_schemes() + self._reload_schemes_ui(select_id=sid) + self._append_log(f"Схемы сохранены: {path}") + messagebox.showinfo("md2gost", f"Сохранено:\n{path}") + + def _open_schemes_file(self) -> None: + path = ensure_user_schemes() + try: + if os.name == "nt": + os.startfile(str(path)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + import subprocess + subprocess.call(("open", str(path))) + else: + import subprocess + subprocess.call(("xdg-open", str(path))) + except OSError as exc: + messagebox.showerror("md2gost", f"Не удалось открыть:\n{path}\n{exc}") + + def _reset_include_cache(self) -> None: + n = cache_entry_count() + if not messagebox.askyesno( + "md2gost", + f"Сбросить кэш includes ({n} записей)?\n" + "Удалятся индекс и только файлы из него.\n" + "При следующем рендере URL скачаются снова.", + ): + return + removed = reset_include_cache() + self._append_log(f"Кэш includes сброшен, удалено файлов: {removed}") + messagebox.showinfo("md2gost", f"Кэш сброшен. Удалено файлов: {removed}") + + def _combo(self, parent, row: int, label: str, var: tk.StringVar, values: list[str], tip: str = "") -> None: + ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=3) + box = ttk.Combobox(parent, textvariable=var, values=values, state="readonly") + box.grid(row=row, column=1, sticky="ew", pady=3) + if tip: + _tip(box, tip) + + def _row_path(self, parent, row, label, var, browse, clearable=False, tip: str = ""): + ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=3) + cell = ttk.Frame(parent) + cell.grid(row=row, column=1, sticky="ew", pady=3) + cell.columnconfigure(0, weight=1) + entry = ttk.Entry(cell, textvariable=var) + entry.grid(row=0, column=0, sticky="ew") + if tip: + _tip(entry, tip) + btn = ttk.Button(cell, text="Обзор…", command=browse) + btn.grid(row=0, column=1, padx=(6, 0)) + _tip(btn, "Выбрать файл") + if clearable: + clear_btn = ttk.Button(cell, text="Сброс", command=lambda: var.set("")) + clear_btn.grid(row=0, column=2, padx=(4, 0)) + _tip(clear_btn, "Очистить поле") + + def _setup_dnd(self) -> None: + backend = enable_file_drop(self.zone, self._on_drop) + if backend == "none": + backend = enable_file_drop(self.root, self._on_drop) + self._dnd_backend = backend + if backend == "none": + self._append_log("Перетаскивание недоступно — выберите файл кликом по области.") + else: + self._append_log("Можно перетаскивать .md из проводника в окно.") + + def _on_drop(self, paths: list[str]) -> None: + md = first_markdown(paths) + if md: + self._set_markdown(md) + self._flash_zone(ZONE_OK) + return + if paths: + self._append_log("Нужен файл .md, а не: " + ", ".join(os.path.basename(p) for p in paths)) + self._flash_zone("#f8d7da") + + def _flash_zone(self, color: str) -> None: + self.zone.configure(bg=color) + self.zone_label.configure(bg=color) + self.zone_path.configure(bg=color) + self.root.after(700, lambda: self._set_zone_colors(ZONE if self._last_md else ZONE)) + + def _set_zone_colors(self, color: str) -> None: + self.zone.configure(bg=color) + self.zone_label.configure(bg=color) + self.zone_path.configure(bg=color) + + def _browse_md(self) -> None: + path = filedialog.askopenfilename( + title="Исходный Markdown", + filetypes=[("Markdown", "*.md"), ("Все файлы", "*.*")], + ) + if path: + self._set_markdown(path) + + def _browse_output(self) -> None: + initial = self.output_var.get() or (default_output_path(self._last_md) if self._last_md else "report.docx") + path = filedialog.asksaveasfilename( + title="Сохранить DOCX", + defaultextension=".docx", + initialfile=os.path.basename(initial), + initialdir=os.path.dirname(os.path.abspath(initial)) if initial else "", + filetypes=[("Word", "*.docx")], + ) + if path: + self._auto_output = False + self.output_var.set(path) + + def _browse_template(self) -> None: + self._pick_into(self.template_var, "Шаблон DOCX", [("Word", "*.docx")]) + + def _browse_title(self) -> None: + self._pick_into(self.title_var, "Титульный лист", [("Word", "*.docx")]) + + def _browse_assignment(self) -> None: + self._pick_into(self.assignment_var, "Бланк задания", [("Word", "*.docx")]) + + def _browse_jar(self) -> None: + self._pick_into(self.plantuml_var, "Свой plantuml.jar", [("JAR", "*.jar"), ("Все файлы", "*.*")]) + self._refresh_diagram_status() + + def _refresh_diagram_status(self) -> None: + label = getattr(self, "diag_status", None) + if label is None: + return + try: + if not label.winfo_exists(): + return + except tk.TclError: + return + explicit = self.plantuml_var.get().strip() or None + label.configure(text=diagram_engine_status(explicit)) + + def _download_plantuml(self) -> None: + from .diagram_renderer import cached_plantuml_path, fetch_plantuml_jar, vendor_plantuml_path + dest = vendor_plantuml_path() + try: + dest.parent.mkdir(parents=True, exist_ok=True) + probe = dest.parent / ".write_test" + probe.write_text("ok", encoding="utf-8") + probe.unlink() + except OSError: + dest = cached_plantuml_path() + self._append_log(f"Скачиваю PlantUML → {dest}") + if fetch_plantuml_jar(dest): + self._refresh_diagram_status() + messagebox.showinfo("md2gost", f"PlantUML сохранён:\n{dest}\n\nДля локальной отрисовки нужна Java.") + else: + messagebox.showerror( + "md2gost", + "Не удалось скачать plantuml.jar.\n" + "Можно ничего не делать — диаграммы нарисует kroki.io.", + ) + + def _pick_into(self, var: tk.StringVar, title: str, types) -> None: + path = filedialog.askopenfilename(title=title, filetypes=types) + if path: + var.set(path) + + def _on_output_edited(self, *_args) -> None: + if not self._last_md: + return + current = self.output_var.get().strip() + auto = default_output_path(self._last_md) + self._auto_output = (not current) or os.path.normcase(os.path.abspath(current)) == os.path.normcase(auto) + + def _set_markdown(self, path: str, from_user_output: bool = False) -> None: + path = os.path.abspath(path) + self._last_md = path + self.zone_label.configure(text=os.path.basename(path)) + self.zone_path.configure(text=path) + if not from_user_output and self._auto_output: + self.output_var.set(default_output_path(path)) + self._auto_output = True + self._append_log(f"Файл: {path}") + + def _apply_request(self, req: ConvertRequest) -> None: + self.type_var.set(_display(DOC_TYPE_LABELS, req.doc_type if req.doc_type in DOC_TYPES else "practice")) + self.heading_var.set(_display(HEADING_LABELS, req.heading_numbering or DEFAULT_HEADING_NUMBERING)) + self.toc_var.set(_display(TOC_LABELS, req.toc_mode or DEFAULT_TOC_MODE)) + self.table_var.set(_display(TABLE_LABELS, req.table_continuation or DEFAULT_TABLE_CONTINUATION)) + self.listing_var.set(_display(TABLE_LABELS, req.listing_continuation or DEFAULT_LISTING_CONTINUATION)) + self.fallback_var.set(_display(FALLBACK_LABELS, req.diagram_fallback or "remote")) + self.format_var.set(_display(FORMAT_LABELS, req.diagram_format or "png")) + scale = getattr(req, "diagram_scale", 2.0) or 2.0 + self.scale_var.set(str(scale).rstrip("0").rstrip(".") if isinstance(scale, float) else str(scale)) + self.emdash_var.set(bool(req.emdash_to_hyphen)) + self.hr_pagebreak_var.set(bool(req.hr_pagebreak)) + self.open_var.set(True) + self.check_var.set(bool(req.check)) + self.check_only_var.set(bool(req.check_only)) + self.strict_var.set(bool(req.strict)) + self.check_pages_var.set(bool(getattr(req, "check_pages", False))) + self.table_repeat_header_var.set(bool(getattr(req, "table_repeat_header", False))) + self.highlight_var.set(bool(req.syntax_highlighting)) + self.debug_var.set(bool(req.debug)) + self._debug_next = bool(req.debug) + self._sync_debug_menu() + self.template_var.set(req.template or "") + self.title_var.set(req.title or "") + self.assignment_var.set(req.assignment or "") + self.plantuml_var.set(req.plantuml_jar or "") + self.kroki_var.set(req.kroki_url or "") + self._refresh_diagram_status() + if req.output: + self.output_var.set(req.output) + self._auto_output = False + + def _collect(self) -> ConvertRequest: + return ConvertRequest( + filename=self._last_md, + output=self.output_var.get().strip() or None, + template=self.template_var.get().strip() or None, + doc_type=_combo_key(self.type_var.get(), DOC_TYPES, "coursework"), + heading_numbering=_combo_key(self.heading_var.get(), HEADING_NUMBERING_MODES, DEFAULT_HEADING_NUMBERING), + toc_mode=_combo_key(self.toc_var.get(), TOC_MODES, DEFAULT_TOC_MODE), + table_continuation=_combo_key(self.table_var.get(), TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION), + listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION), + emdash_to_hyphen=self.emdash_var.get(), + hr_pagebreak=self.hr_pagebreak_var.get(), + title=self.title_var.get().strip() or None, + assignment=self.assignment_var.get().strip() or None, + check=self.check_var.get() or self.check_only_var.get(), + check_only=self.check_only_var.get(), + check_pages=self.check_pages_var.get(), + table_repeat_header=self.table_repeat_header_var.get(), + strict=self.strict_var.get(), + syntax_highlighting=self.highlight_var.get(), + plantuml_jar=self.plantuml_var.get().strip() or None, + kroki_url=self.kroki_var.get().strip() or None, + diagram_fallback=_combo_key(self.fallback_var.get(), ("remote", "local", "off"), "remote"), + diagram_format=_combo_key(self.format_var.get(), ("png", "svg"), "png"), + diagram_scale=self._parse_diagram_scale(), + debug=self.debug_var.get(), + open_when_done=self.open_var.get(), + ) + + def _parse_diagram_scale(self) -> float: + raw = (self.scale_var.get() or "2").strip().replace(",", ".") + try: + value = float(raw) + except ValueError: + return 2.0 + return value if value >= 1 else 1.0 + + def _convert(self) -> None: + if self._busy: + return + req = self._collect() + if not req.filename: + messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.") + return + if not req.check_only and req.output and os.path.isfile(req.output): + if not messagebox.askyesno("md2gost", f"Файл уже есть:\n{req.output}\n\nПерезаписать?"): + return + self._busy = True + self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM) + self.progress.start(12) + self._append_log("Конвертация…") + + def work(): + result = convert(req, log=lambda m: self.root.after(0, self._append_log, m)) + self.root.after(0, self._on_done, result) + + threading.Thread(target=work, daemon=True).start() + + def _on_done(self, result) -> None: + self._busy = False + self.convert_btn.configure(state=tk.NORMAL, bg=NAVY) + self.progress.stop() + if self._debug_next or self.debug_var.get(): + self.debug_var.set(False) + self._debug_next = False + self._sync_debug_menu() + self._append_log("Дебаг сброшен после этой сборки.") + if result.ok: + if result.output_path: + self._append_log("Готово.") + elif result.check_report: + self._append_log("Проверка завершена.") + else: + self._append_log(result.message or "Ошибка") + messagebox.showerror("md2gost", result.message or "Ошибка конвертации") + + def _append_log(self, text: str) -> None: + if not text: + return + if not hasattr(self, "log"): + return + self.log.configure(state=tk.NORMAL) + if not text.endswith("\n"): + text += "\n" + self.log.insert(tk.END, text) + self.log.see(tk.END) + self.log.configure(state=tk.DISABLED) + + def run(self) -> None: + self.root.mainloop() + + +def run_gui(initial: ConvertRequest | None = None) -> None: + Md2GostApp(initial).run() + + +def main() -> None: + run_gui() + + +if __name__ == "__main__": + main() diff --git a/md2gost/help_content.py b/md2gost/help_content.py new file mode 100644 index 0000000..8cfc18d --- /dev/null +++ b/md2gost/help_content.py @@ -0,0 +1,258 @@ +"""Usage text and AI prompt files for the GUI pages.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from . import package_dir + +USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32) + +КАК ПОЛЬЗОВАТЬСЯ ОКНОМ +1. Перетащите .md в верхнюю область (или кликните по ней). +2. Выберите тип документа и параметры в блоке «Основные». + Шаблон / титул / задание — Настройки → Файлы. + PlantUML / Kroki — Настройки → Диаграммы. + Свои UML-схемы — меню «Шаблоны UML». +3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»). + Дебаг (меню сверху) — следующая сборка с отладочными данными в документе. + +Типы: practice (по умолчанию) / coursework / vkr — ГОСТ МИРЭА; PIS_custom — отчёт по практикам; APID_coursework — курсовая АПИД. + +Полезные галочки +• «—» → «-» — заменить типографское тире на дефис (по умолчанию **выкл.**; методичка требует «—»). +• --- → разрыв страницы — по умолчанию выкл. (строка --- игнорируется). Вкл. — page break в Word. +• Проверить по ТЗ — замечания по структуре, «рис.», источникам. +• Проверить вёрстку в Word — полупустые страницы (эвристика, возможны ложные срабатывания; нужны Word + pywin32). +• Титул / задание — отдельные DOCX, вставляются перед телом отчёта. + +CLI (тот же движок) + python -m md2gost report.md -o report.docx --type coursework --check + python -m md2gost --gui + md2gost.exe report.md --type PIS_custom --title title.docx + md2gost.exe report.md --schemes path/to/md2gost.schemes.json + +СИНТАКСИС MARKDOWN + +Спецразделы (без номера, ПРОПИСНЫЕ, звёздочка): + # *СОДЕРЖАНИЕ + [TOC] + # *ВВЕДЕНИЕ + # *ЗАКЛЮЧЕНИЕ + # *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ + # *ПРИЛОЖЕНИЯ + (после ПРИЛОЖЕНИЯ — перечень основным текстом, затем ## Приложение А Название) + +Нумерованные разделы: # 1 Название ## 1.1 Подраздел +Точку в конце заголовка не ставить. +СОДЕРЖАНИЕ и СПИСОК — по центру; Введение / Заключение / ПРИЛОЖЕНИЯ — слева. + +Рисунок + Текст со ссылкой на @Рисунок:arch. + ![описание](images/arch.png "%arch Название рисунка") +В тексте пишите «Рисунок», не «рис.» + +Таблица + %tbl1 Название + | A | B | + |---|---| + | 1 | 2 | +Ссылка: @Таблица:tbl1 +Склеивание: ^ — ячейка сверху (rowspan), > — ячейка слева (colspan). +Не ставить ^/> в шапке; > — не в первом столбце. + +Листинг + %code1 Название + ```python + print("ok") + ``` + +Диаграмма (PlantUML / Mermaid / схемы) → рисунок + %usecase1 Диаграмма прецедентов +listing + ```uml + @startuml + actor User + User --> (Login) + @enduml + ``` + + Широкая схема/таблица на альбомной странице: + %arch1 Архитектура +landscape + ```uml-c4 + … + ``` + + Mermaid (через Kroki, свой URL или kroki.io): + %seq Последовательность +listing + ```mermaid + sequenceDiagram + Alice->>Bob: hello + ``` + ++listing — ещё и листинг с исходником. ++landscape — отдельная альбомная страница (A4, повёрт на 90°) вокруг рисунка/таблицы, затем снова книжная. +Языки: uml, plantuml, mermaid / mmd, или uml- / из файла схем (c4, usecase, bpmn, …). +BPMN 2.0: оградка ```bpmn / ```uml-bpmn, макросы Start, UserTask, XOR, Flow, Pool. +Формат в Word: PNG по умолчанию (PlantUML рисуется ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+). +IDEF0 конвертер не рисует — вставляйте готовый PNG как обычный Рисунок. +Подробнее — Справка → Схемы и раздел ниже в инструкции. + +Формула (номер только если есть ссылка) + %eq1 + $$ E = mc^2 $$ + См. @Формула:eq1 + +Источники + В тексте: [1] + В списке: [1]: Иванов И. И. Название. — М.: Наука, 2023. — 120 с. + +Разрыв страницы + По умолчанию --- игнорируется. + Галочка «--- → разрыв страницы» или --hr-pagebreak: пустая строка, ---, пустая строка. + +Нумерация заголовков + manual — цифры уже в md (# 1 …); auto — нумерует Word. + +Содержание + native — поле Word (обновить при открытии); manual — собирает md2gost. + +Продолжение таблиц / листингов + word — после сборки Word COM режет по реальной пагинации и вставляет «Продолжение…» + (по умолчанию; нужны Windows + Word + pywin32). + off — не резать, Word сам переносит. + legacy / caption — режем по оценке высоты в md2gost (может не совпасть с Word). + +Промпт для ИИ — Справка → Промпт для ИИ: скопируйте и вставьте в ChatGPT / Cursor / Copilot, затем дайте тему и черновик. +""" + +SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML) + +Зачем +В markdown пишете только «тело» диаграммы. Обёртка (@startuml, !include, тема) +берётся из схемы в файле md2gost.schemes.json. + +Первый запуск +Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m) +создаётся md2gost.schemes.json из встроенного шаблона. +Если файл уже есть — программа его не перезаписывает (ваши схемы сохраняются). + +Оградка в markdown + ```uml — обычный PlantUML (или ```plantuml) + ```uml-c4 — схема с id «c4» (то же, что ```c4) + ```uml-usecase — схема «usecase» + ```bpmn / ```uml-bpmn — BPMN 2.0 (макросы в diagrams/BPMN.puml) + ```mermaid / ```mmd — Mermaid через Kroki (не PlantUML) +Встроенные пресеты PlantUML: c4, c4context, c4component, usecase, bpmn. + +Пример + +%arch Архитектура +listing + +```uml-c4 +Person(user, "Студент") +System(app, "Портал") +Rel(user, app, "логин") +``` + +```bpmn +StartMessage(s, "заявка") +UserTask(t, "Проверить") +XOR(gw) +End(e_ok) +End(e_no) +Flow(s, t) +Flow(t, gw) +CondFlow(gw, e_ok, "да") +DefaultFlow(gw, e_no) +``` + +Поля схемы в JSON + title — подпись в GUI + version — версия схемы + author — автор + docs — шпаргалка синтаксиса (чтобы вспомнить макросы) + ai-prompt — заготовка промпта для ИИ / будущего MCP + includes — список файлов или http(s):// URL на .puml + prefix — текст перед телом (часто @startuml) + postfix — текст после тела (часто @enduml) + theme — опционально !theme … + +Свои схемы +1. Меню «Шаблоны UML» в GUI — добавьте / отредактируйте и сохраните. +2. Или откройте md2gost.schemes.json в редакторе («Открыть JSON» / «Открыть файл схем»). +3. CLI: --schemes путь.json; также подхватывается md2gost.schemes.json рядом с .md. + +Кэш includes из интернета +URL из includes (и !include https://… внутри .puml) при первом рендере +скачиваются. Индекс — md2gost.include-cache.json (только пары URL → файл). +Файлы лежат в папке include-cache/. Уже скачанные файлы не перезаписываются. +Повторный рендер без сети берёт путь из индекса. +«Сбросить кэш includes» в Настройки → Диаграммы или в «Шаблоны UML» удаляет индекс +и только файлы, перечисленные в нём. + +BPMN 2.0 (```bpmn) + Пул: Pool(alias, "Участник") { Lane(alias, "Роль") { … } } + События: Start, StartMessage, StartTimer, CatchMessage, ThrowMessage, + CatchTimer, CatchError, End, EndMessage, EndError, EndTerminate + Задачи: Task / UserTask / ServiceTask / ScriptTask / ManualTask / + SendTask / ReceiveTask / BusinessRuleTask / SubProcess + Шлюзы: XOR (Exclusive), AND (Parallel), OR (Inclusive), EventBased + Потоки: Flow, CondFlow(from, to, "условие"), DefaultFlow, MessageFlow + Данные: DataObject, DataStore, Annotation + Граница: BoundaryError/Timer/Message + Attach(task, event) + Sequence Flow только внутри пула; между пулами — MessageFlow. + +Чего нет + IDEF0 / DFD — PlantUML не умеет; вставляйте готовый PNG как Рисунок. + У BPMN нет «прилипания» boundary-события к кромке задачи (ставьте Attach) + и нет настоящей двойной окружности у intermediate (толщина линии). + +Рендер +UML: Java + plantuml.jar (локально) → иначе локальный Kroki → иначе kroki.io. +Mermaid: только Kroki (свой --kroki-url / localhost / kroki.io). +Формат: PNG по умолчанию (PlantUML ~scale 2 для чёткости, размер на странице как при 1; --diagram-scale); --diagram-format svg — PNG + SVG (svgBlip в Word 2016+). ++landscape у %подписи — альбомная страница под широкий рисунок/таблицу. +""" + +PROMPT_FILES = ( + ("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"), + ("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"), +) + + +def prompt_search_dirs() -> list[Path]: + dirs: list[Path] = [] + here = Path(package_dir()) + dirs.append(here / "prompts") + dirs.append(here.parent / "prompts") + if getattr(sys, "frozen", False): + mei = getattr(sys, "_MEIPASS", None) + if mei: + dirs.append(Path(mei) / "prompts") + dirs.append(Path(sys.executable).resolve().parent / "prompts") + seen: set[str] = set() + out: list[Path] = [] + for path in dirs: + key = str(path.resolve()) if path.exists() else str(path) + if key in seen: + continue + seen.add(key) + out.append(path) + return out + + +def load_prompt_catalog() -> list[tuple[str, str, str]]: + """Return list of (filename, title, text). Missing files are skipped.""" + catalog: list[tuple[str, str, str]] = [] + dirs = prompt_search_dirs() + for name, title in PROMPT_FILES: + text = None + for folder in dirs: + candidate = folder / name + if candidate.is_file(): + text = candidate.read_text(encoding="utf-8") + break + if text: + catalog.append((name, title, text)) + return catalog diff --git a/md2gost/layout_tracker.py b/md2gost/layout_tracker.py index 57aec08..4290516 100644 --- a/md2gost/layout_tracker.py +++ b/md2gost/layout_tracker.py @@ -51,3 +51,13 @@ class LayoutTracker: def new_page(self): self._state.new_page() + + def set_page_size(self, max_height: Length, max_width: Length) -> None: + """Switch usable area (e.g. portrait ↔ landscape) and start a fresh page.""" + self._state.max_height = max_height + self._state.max_width = max_width + # Align to a page boundary under the *new* geometry. + rem = self._state.remaining_page_height + if rem != max_height: + self._state.add_height(rem) + self._is_new_page = True diff --git a/md2gost/page_fill_check.py b/md2gost/page_fill_check.py new file mode 100644 index 0000000..0708eab --- /dev/null +++ b/md2gost/page_fill_check.py @@ -0,0 +1,226 @@ +"""Post-conversion heuristic: half-empty pages inside an unfinished section. + +Requires Windows + Microsoft Word + pywin32. Findings are ALWAYS heuristic — +false positives are expected (large figures, end of subsection, odd breaks). +Never treat as --strict errors. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Unused fraction of the text area below the last content on the page. +EMPTY_FRACTION_THRESHOLD = 0.40 + +FRONT_MATTER_HINTS = ( + "СОДЕРЖАНИЕ", + "ТИТУЛ", + "ЗАДАНИЕ", +) + + +@dataclass +class PageMetric: + """Synthetic / measured metrics for one page (CI-friendly).""" + + page_index: int # 1-based + content_bottom_frac: float # 0..1, how far down the text area content reaches + section_title: str + next_section_title: str | None + is_landscape: bool = False + is_last_doc_page: bool = False + mostly_figure_or_table: bool = False + + +@dataclass +class PageFillIssue: + page_index: int + message: str + severity: str = "heuristic" + + +def evaluate_page_fill( + pages: list[PageMetric], + *, + empty_threshold: float = EMPTY_FRACTION_THRESHOLD, +) -> list[PageFillIssue]: + """Pure heuristic over page metrics (no Word). Safe for unit tests.""" + issues: list[PageFillIssue] = [] + for i, page in enumerate(pages): + if page.is_landscape: + continue + if page.mostly_figure_or_table: + continue + if page.is_last_doc_page: + continue + title_u = (page.section_title or "").upper() + if any(h in title_u for h in FRONT_MATTER_HINTS): + continue + # Last page of this section (next page starts a different H1) + if page.next_section_title and page.next_section_title != page.section_title: + continue + empty_frac = 1.0 - page.content_bottom_frac + if empty_frac <= empty_threshold: + continue + # Same section continues on the next page + if i + 1 >= len(pages): + continue + nxt = pages[i + 1] + if nxt.section_title != page.section_title: + continue + issues.append(PageFillIssue( + page_index=page.page_index, + message=( + f"[heuristic] page.fill: на стр. {page.page_index} пустой низ " + f"~{empty_frac:.0%} полосы, а раздел «{page.section_title}» " + f"продолжается на следующей странице. " + f"Проверка эвристическая — возможны ложные срабатывания " + f"(крупный рисунок, конец пункта, нестандартный разрыв)." + ), + )) + return issues + + +def check_docx_page_fill(path: str) -> tuple[list[PageFillIssue], str]: + """ + Open DOCX in Word, repaginate, collect metrics, evaluate. + + Returns (issues, status_message). On missing Word/pywin32 returns ([], reason). + """ + try: + import win32com.client # type: ignore + except ImportError: + return [], ( + "Проверка вёрстки пропущена: нужен пакет pywin32 " + "(pip install pywin32) и Microsoft Word." + ) + + word = None + doc = None + try: + word = win32com.client.DispatchEx("Word.Application") + word.Visible = False + word.DisplayAlerts = 0 + abs_path = str(path) + doc = word.Documents.Open(abs_path, ReadOnly=True) + doc.Repaginate() + + pages_metrics: list[PageMetric] = [] + page_count = int(doc.ComputeStatistics(2)) # wdStatisticPages + for page_no in range(1, page_count + 1): + try: + metric = _measure_page(doc, page_no, page_count) + except Exception: + continue + if metric is not None: + pages_metrics.append(metric) + + issues = evaluate_page_fill(pages_metrics) + if not issues: + return [], ( + "Проверка вёрстки (Word): замечаний по полупустым страницам нет " + "(эвристика; возможны пропуски)." + ) + return issues, ( + f"Проверка вёрстки (Word): найдено замечаний — {len(issues)} " + f"(все эвристические, могут быть ложными)." + ) + except Exception as exc: + return [], f"Проверка вёрстки пропущена: не удалось открыть Word ({exc})." + finally: + try: + if doc is not None: + doc.Close(False) + except Exception: + pass + try: + if word is not None: + word.Quit() + except Exception: + pass + + +def _measure_page(doc, page_no: int, page_count: int) -> PageMetric | None: + """Best-effort measurement via Word COM selection / page setup.""" + selection = doc.Application.Selection + selection.GoTo(What=1, Which=1, Count=page_no) # wdGoToPage, wdGoToAbsolute + + section = selection.Sections(1) + ps = section.PageSetup + is_landscape = bool(int(ps.Orientation) == 1) # wdOrientLandscape + + page_h = float(ps.PageHeight) + top = float(ps.TopMargin) + bottom = float(ps.BottomMargin) + text_h = max(page_h - top - bottom, 1.0) + + start = int(selection.Start) + if page_no < page_count: + selection.GoTo(What=1, Which=1, Count=page_no + 1) + end = int(selection.Start) - 1 + else: + end = int(doc.Content.End) + if end < start: + end = start + rng = doc.Range(start, end) + + try: + # wdVerticalPositionRelativeToPage = 6 + vpos = float(rng.Information(6)) + content_bottom = max(0.0, min(1.0, (vpos - top) / text_h)) + except Exception: + content_bottom = 1.0 + + section_title = _heading_near(doc, start) + next_title = None + if page_no < page_count: + try: + selection.GoTo(What=1, Which=1, Count=page_no + 1) + next_title = _heading_near(doc, int(selection.Start)) + except Exception: + next_title = None + + mostly_object = False + try: + text_len = len((rng.Text or "").strip()) + if rng.Tables.Count >= 1 and text_len < 80: + mostly_object = True + if rng.InlineShapes.Count >= 1 and text_len < 80: + mostly_object = True + except Exception: + pass + + return PageMetric( + page_index=page_no, + content_bottom_frac=content_bottom, + section_title=section_title or "", + next_section_title=next_title, + is_landscape=is_landscape, + is_last_doc_page=(page_no == page_count), + mostly_figure_or_table=mostly_object, + ) + + +def _heading_near(doc, pos: int) -> str: + """Walk backwards for nearest Heading 1 style paragraph.""" + try: + p = doc.Range(pos, pos).Paragraphs(1) + for _ in range(80): + style = str(p.Style) + if "Heading 1" in style or "Заголовок 1" in style: + return (p.Range.Text or "").strip().replace("\r", "") + if p.Range.Start <= 1: + break + p = p.Previous() + if p is None: + break + except Exception: + pass + return "" + + +def format_page_fill_report(issues: list[PageFillIssue], status: str) -> str: + lines = [status] + for i in issues: + lines.append(f" [{i.severity}] page:{i.page_index}: {i.message}") + return "\n".join(lines) diff --git a/md2gost/page_geometry.py b/md2gost/page_geometry.py new file mode 100644 index 0000000..2e68ace --- /dev/null +++ b/md2gost/page_geometry.py @@ -0,0 +1,142 @@ +"""A4 page geometry helpers for portrait / landscape sections.""" + +from __future__ import annotations + +from docx.enum.section import WD_ORIENT +from docx.enum.text import WD_PARAGRAPH_ALIGNMENT +from docx.oxml.ns import qn +from docx.shared import Mm, Pt + +from .util import create_element + +# GOST-like A4 margins (same as styles._apply_common_page_and_body) +MARGIN_LEFT = Mm(30) +MARGIN_RIGHT = Mm(10) +MARGIN_TOP = Mm(20) +MARGIN_BOTTOM = Mm(20) + +A4_SHORT = Mm(210) +A4_LONG = Mm(297) + + +def is_landscape_section(section) -> bool: + """True if section is (or should be treated as) landscape A4.""" + try: + if section.orientation == WD_ORIENT.LANDSCAPE: + return True + except Exception: + pass + return int(section.page_width) > int(section.page_height) + + +def apply_section_geometry(section, *, landscape: bool) -> None: + """ + Set orientation and page size. + + Set orientation first (python-docx may swap w/h on change), then force A4 dims. + """ + target = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT + try: + section.orientation = target + except Exception: + pass + + if landscape: + section.page_width = A4_LONG + section.page_height = A4_SHORT + else: + section.page_width = A4_SHORT + section.page_height = A4_LONG + + section.left_margin = MARGIN_LEFT + section.right_margin = MARGIN_RIGHT + section.top_margin = MARGIN_TOP + section.bottom_margin = MARGIN_BOTTOM + + # Explicit orient on pgSz for Word. + pg_sz = section._sectPr.find(qn("w:pgSz")) + if pg_sz is None: + pg_sz = section._sectPr._add_pgSz() + if landscape: + pg_sz.set(qn("w:orient"), "landscape") + # Re-assert after XML tweak (some builds reshuffle). + section.page_width = A4_LONG + section.page_height = A4_SHORT + else: + if pg_sz.get(qn("w:orient")) is not None: + del pg_sz.attrib[qn("w:orient")] + section.page_width = A4_SHORT + section.page_height = A4_LONG + + # Vertical align: center on landscape (figures/tables only); top on portrait + sect_pr = section._sectPr + for el in list(sect_pr.findall(qn("w:vAlign"))): + sect_pr.remove(el) + if landscape: + sect_pr.append(create_element("w:vAlign", {"w:val": "center"})) + + +def content_size(*, landscape: bool) -> tuple: + """Return (max_height, max_width) usable content area for LayoutTracker.""" + if landscape: + page_w, page_h = A4_LONG, A4_SHORT + else: + page_w, page_h = A4_SHORT, A4_LONG + max_height = page_h - MARGIN_TOP - MARGIN_BOTTOM + max_width = page_w - MARGIN_LEFT - MARGIN_RIGHT + return max_height, max_width + + +def apply_centered_page_footer(section) -> None: + """Centered PAGE field, Times New Roman 12 (body section style).""" + footer = section.footer + footer.is_linked_to_previous = False + if not footer.paragraphs: + footer.add_paragraph() + paragraph = footer.paragraphs[0] + paragraph.clear() + paragraph.paragraph_format.first_line_indent = 0 + paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + run = paragraph.add_run() + run.font.name = "Times New Roman" + run.font.size = Pt(12) + paragraph._p.append(create_element("w:fldSimple", { + "w:instr": "PAGE \\* MERGEFORMAT", + })) + + +def clear_section_footer(section) -> None: + """Empty footer (no PAGE) — for title / assignment / TOC sections.""" + footer = section.footer + footer.is_linked_to_previous = False + for p in footer.paragraphs: + p.clear() + if not footer.paragraphs: + footer.add_paragraph() + + +def ensure_continuous_page_numbers(document) -> None: + """ + After docxcompose of title/assignment: no PAGE on early sections, + continuous numbering (do not restart at 1 on body section). + """ + from docx.oxml.ns import qn + + sections = list(document.sections) + if not sections: + return + + # Heuristic: sections before the first that already has a PAGE field / + # or first N-1 if title was prepended — clear footer on all but keep + # continuous pgNumType. Body renderer already puts PAGE on body section; + # after compose, early sections may inherit footers — clear empty ones + # that belong to front matter (no Heading-like body content is hard to + # detect), so: clear footer on every section that has no PAGE field, and + # strip w:pgNumType start=1 everywhere. + for section in sections: + sect_pr = section._sectPr + for child in list(sect_pr): + if child.tag == qn("w:pgNumType"): + # Keep continuous: remove start attribute if present + if child.get(qn("w:start")) is not None: + del child.attrib[qn("w:start")] diff --git a/md2gost/parser_.py b/md2gost/parser_.py index a4a9421..832c26a 100644 --- a/md2gost/parser_.py +++ b/md2gost/parser_.py @@ -1,7 +1,7 @@ from collections.abc import Generator from docx import Document -from marko.block import BlankLine +from marko.block import BlankLine, ThematicBreak from .extended_markdown import markdown, Caption from .renderable.caption import CaptionInfo @@ -12,13 +12,14 @@ from .renderable_factory import RenderableFactory class Parser: """Parses given markdown string and returns Renderable elements""" - def __init__(self, document: Document, text: str): + def __init__(self, document: Document, text: str, hr_pagebreak: bool = False): self._document = document self._parsed = markdown.parse(text) self._caption_info: CaptionInfo | None = None + self._hr_pagebreak = hr_pagebreak def parse(self) -> Generator[Renderable, None, None]: - factory = RenderableFactory(self._document._body) + factory = RenderableFactory(self._document._body, hr_pagebreak=self._hr_pagebreak) for marko_element in self._parsed.children: if isinstance(marko_element, BlankLine): @@ -29,8 +30,12 @@ class Parser: marko_element.unique_name, marko_element.text, getattr(marko_element, "with_listing", False), + getattr(marko_element, "landscape", False), ) continue + if isinstance(marko_element, ThematicBreak) and not self._hr_pagebreak: + continue + yield factory.create(marko_element, self._caption_info) self._caption_info = None diff --git a/md2gost/pipeline.py b/md2gost/pipeline.py new file mode 100644 index 0000000..d276609 --- /dev/null +++ b/md2gost/pipeline.py @@ -0,0 +1,303 @@ +"""Shared Markdown → DOCX pipeline for CLI and GUI.""" + +from __future__ import annotations + +import logging +import os +import platform +import subprocess +import traceback +from dataclasses import dataclass +from getpass import getuser +from typing import Callable + +from docx import Document + +from . import package_dir +from .checker import check_markdown, format_report +from .converter import Converter +from .profiles import ( + DEFAULT_HEADING_NUMBERING, + DEFAULT_TABLE_CONTINUATION, + DEFAULT_LISTING_CONTINUATION, + DEFAULT_TOC_MODE, + get_profile, +) + +LogFn = Callable[[str], None] + + +@dataclass +class ConvertRequest: + filename: str = "" + output: str | None = None + template: str | None = None + doc_type: str = "practice" + heading_numbering: str = DEFAULT_HEADING_NUMBERING + toc_mode: str = DEFAULT_TOC_MODE + table_continuation: str = DEFAULT_TABLE_CONTINUATION + listing_continuation: str = DEFAULT_LISTING_CONTINUATION + emdash_to_hyphen: bool = False + hr_pagebreak: bool = False + title: str | None = None + assignment: str | None = None + check: bool = False + check_only: bool = False + strict: bool = False + syntax_highlighting: bool = False + plantuml_jar: str | None = None + kroki_url: str | None = None + diagram_fallback: str = "remote" + diagram_format: str = "png" + diagram_scale: float = 2.0 + schemes_path: str | None = None + debug: bool = False + open_when_done: bool = False + check_pages: bool = False + table_repeat_header: bool = False + + +@dataclass +class ConvertResult: + ok: bool + exit_code: int = 0 + output_path: str | None = None + check_report: str = "" + message: str = "" + + +def default_output_path(filename: str) -> str: + base = os.path.basename(filename) + if base.lower().endswith(".md"): + base = base[:-3] + return os.path.join(os.path.dirname(os.path.abspath(filename)), base + ".docx") + + +def default_template_path() -> str: + return os.path.join(package_dir(), "Template.docx") + + +def open_document(path: str) -> None: + system = platform.system() + if system == "Darwin": + subprocess.call(("open", path)) + elif system == "Windows": + os.startfile(path) # type: ignore[attr-defined] + else: + subprocess.call(("xdg-open", path)) + + +def _fix_front_matter_after_compose(document, *, had_title: bool, had_assignment: bool) -> None: + """Clear PAGE on title/assignment sections; keep continuous page numbers.""" + from .page_geometry import clear_section_footer, ensure_continuous_page_numbers + + ensure_continuous_page_numbers(document) + n_front = int(bool(had_title)) + int(bool(had_assignment)) + for i, section in enumerate(document.sections): + if i < n_front: + clear_section_footer(section) + else: + break + ensure_continuous_page_numbers(document) + + +def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult: + """Run check and/or conversion. Does not re-raise; errors go into ConvertResult.""" + emit: LogFn = log if log is not None else print + + def fail(code: int, message: str, check_report: str = "") -> ConvertResult: + emit(message) + return ConvertResult(False, code, check_report=check_report, message=message) + + filename = (req.filename or "").strip() + # Standalone page-fill check on an existing DOCX + if req.check_pages and filename.lower().endswith(".docx") and not filename.lower().endswith(".md"): + from .page_fill_check import check_docx_page_fill, format_page_fill_report + issues, status = check_docx_page_fill(filename) + report = format_page_fill_report(issues, status) + emit(report) + return ConvertResult(True, 0, check_report=report, message=report) + + if not filename: + return fail(2, "Укажите исходный markdown-файл") + if not filename.lower().endswith(".md"): + return fail(1, "Исходный файл должен быть в формате .md") + if not os.path.isfile(filename): + return fail(2, f"Файл не найден: {filename}") + + output = (req.output or "").strip() or None + if output and not output.lower().endswith(".docx"): + return fail(1, "Выходной файл должен быть в формате .docx") + + for label, path in (("титул", req.title), ("задание", req.assignment), ("шаблон", req.template)): + if path and not os.path.isfile(path): + return fail(2, f"Файл ({label}) не найден: {path}") + + if req.syntax_highlighting: + os.environ["SYNTAX_HIGHLIGHTING"] = "1" + else: + os.environ.pop("SYNTAX_HIGHLIGHTING", None) + + from .diagram_renderer import configure_diagrams + + md_dir = os.path.dirname(os.path.abspath(filename)) or "." + configure_diagrams( + plantuml_jar=req.plantuml_jar or None, + kroki_url=req.kroki_url or None, + fallback=req.diagram_fallback, + schemes_path=req.schemes_path or None, + md_dir=md_dir, + diagram_format=req.diagram_format if req.diagram_format in ("png", "svg") else "png", + diagram_scale=req.diagram_scale, + ) + + os.environ["WORKING_DIR"] = md_dir + + with open(filename, encoding="utf-8") as f: + md_text = f.read() + + check_report = "" + if req.check or req.check_only: + issues = check_markdown( + md_text, + req.doc_type, + table_continuation=req.table_continuation, + listing_continuation=req.listing_continuation, + ) + check_report = format_report(issues) + emit(check_report) + errors = [i for i in issues if i.severity == "error"] + if req.strict and errors: + return fail(1, "Проверка ТЗ: есть ошибки (--strict)", check_report) + if req.check_only: + return ConvertResult( + True, 0, check_report=check_report, + message=check_report, + ) + + if not output: + output = default_output_path(filename) + + template = (req.template or "").strip() or default_template_path() + + handler = _CallbackLogHandler(emit) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + root_log = logging.getLogger("md2gost") + root_log.addHandler(handler) + attached_diag = logging.getLogger("md2gost.diagram_renderer") + prev_diag_level = attached_diag.level + attached_diag.setLevel(logging.INFO) + attached_diag.addHandler(handler) + + try: + converter = Converter( + filename, output, template, req.debug, + doc_type=req.doc_type, + heading_numbering=req.heading_numbering, + emdash_to_hyphen=req.emdash_to_hyphen, + toc_mode=req.toc_mode, + table_continuation=req.table_continuation, + listing_continuation=req.listing_continuation, + hr_pagebreak=req.hr_pagebreak, + ) + converter.convert() + document = converter.document + + if req.title or req.assignment: + try: + from docxcompose.composer import Composer + except ImportError: + return fail(3, "Для титула/задания нужен пакет docxcompose", check_report) + from .styles import apply_document_styles + + shell = Document(template) + apply_document_styles(shell, get_profile(req.doc_type).style_preset) + body = shell.element.body + for child in list(body): + if not child.tag.endswith("}sectPr"): + body.remove(child) + + composer = Composer(shell) + if req.title: + composer.append(Document(req.title)) + shell.add_page_break() + if req.assignment: + composer.append(Document(req.assignment)) + shell.add_page_break() + composer.append(document) + document = composer.doc + apply_document_styles(document, get_profile(req.doc_type).style_preset) + _fix_front_matter_after_compose( + document, + had_title=bool(req.title), + had_assignment=bool(req.assignment), + ) + + document.core_properties.author = getuser() + document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)" + document.save(output) + except Exception as exc: + emit(traceback.format_exc()) + return ConvertResult( + False, 1, check_report=check_report, + message=f"Ошибка конвертации: {exc}", + ) + finally: + root_log.removeHandler(handler) + attached_diag.removeHandler(handler) + attached_diag.setLevel(prev_diag_level) + + abs_out = os.path.abspath(output) + emit(f"Generated document: {abs_out}") + + if req.table_continuation == "word" or req.listing_continuation == "word": + from .word_fix import fix_continuations + fix = fix_continuations( + abs_out, + tables=(req.table_continuation == "word"), + listings=(req.listing_continuation == "word"), + repeat_header=bool(req.table_repeat_header), + ) + emit(fix.message) + if fix.details: + for line in fix.details: + emit(line) + if not fix.ok: + check_report = ( + (check_report + "\n" + fix.message).strip() if check_report else fix.message + ) + + if req.check_pages: + from .page_fill_check import check_docx_page_fill, format_page_fill_report + issues, status = check_docx_page_fill(abs_out) + page_report = format_page_fill_report(issues, status) + emit(page_report) + check_report = (check_report + "\n" + page_report).strip() if check_report else page_report + + if req.debug or req.open_when_done: + try: + open_document(abs_out) + except Exception as exc: + emit(f"Не удалось открыть файл: {exc}") + + return ConvertResult( + True, 0, output_path=abs_out, check_report=check_report, + message=f"Generated document: {abs_out}", + ) + + +class _CallbackLogHandler(logging.Handler): + def __init__(self, emit: LogFn): + super().__init__() + self._emit = emit + + def emit(self, record: logging.LogRecord) -> None: + try: + self._emit(self.format(record)) + except Exception: + pass + + +def should_launch_gui(filename: str | None, gui_flag: bool) -> bool: + """GUI if --gui, or if no input file was given (interactive default).""" + return bool(gui_flag or not filename) diff --git a/md2gost/profiles.py b/md2gost/profiles.py index 3e78e7c..ebdb16e 100644 --- a/md2gost/profiles.py +++ b/md2gost/profiles.py @@ -19,12 +19,17 @@ DEFAULT_HEADING_NUMBERING = "manual" TOC_MODES = ("manual", "native") DEFAULT_TOC_MODE = "native" -# off — одна таблица Word, пагинацию делает Word (по умолчанию; без автоподписи) +# off — одна таблица Word, пагинацию делает Word (без автоподписи) # legacy — режем по оценке высоты + «Продолжение…» с page_break_before # caption — режем по оценке + явный PageBreak + «Продолжение…» # soft — синоним off (автоподпись mid-page без точной вёрстки Word невозможна в DOCX) -TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft") -DEFAULT_TABLE_CONTINUATION = "off" +# word — как off при рендере; после save Word COM режет по реальной пагинации + «Продолжение…» +TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft", "word") +DEFAULT_TABLE_CONTINUATION = "word" + +# Same modes as tables (word = post-process via Word COM after save). +LISTING_CONTINUATION_MODES = TABLE_CONTINUATION_MODES +DEFAULT_LISTING_CONTINUATION = DEFAULT_TABLE_CONTINUATION # section — Рисунок 1.1 / 2.1 (ТЗ МИРЭА); continuous — Рисунок 1, 2, 3 (ПИС) NUMBERING_SCOPES = ("section", "continuous") diff --git a/md2gost/renderable/caption.py b/md2gost/renderable/caption.py index 2116194..3825caf 100644 --- a/md2gost/renderable/caption.py +++ b/md2gost/renderable/caption.py @@ -16,7 +16,7 @@ from ..util import create_element # Map category → Word style name CAPTION_STYLES = { "Рисунок": "Caption Figure", - "Таблица": "Caption Table", + "Таблица": "Название таблицы", "Листинг": "Caption Listing", } @@ -26,6 +26,7 @@ class CaptionInfo: unique_name: str | None text: str | None with_listing: bool = False + landscape: bool = False class Caption(Renderable): @@ -40,7 +41,10 @@ class Caption(Renderable): try: self._docx_paragraph.style = style_name except KeyError: - self._docx_paragraph.style = "Caption" + try: + self._docx_paragraph.style = "Caption Table" if category == "Таблица" else "Caption" + except KeyError: + self._docx_paragraph.style = "Caption" # Format: «Рисунок 1.1 — Название» (or « - » if --emdash-to-hyphen) from ..profiles import dash_separator diff --git a/md2gost/renderable/diagram.py b/md2gost/renderable/diagram.py index db2a023..4cfb827 100644 --- a/md2gost/renderable/diagram.py +++ b/md2gost/renderable/diagram.py @@ -14,7 +14,7 @@ from ..rendered_info import RenderedInfo class DiagramFigure(Renderable, RequiresNumbering): - """UML/BPMN/C4 fence → PNG figure (+ optional source listing).""" + """UML / Mermaid / scheme fence → figure (+ optional source listing).""" def __init__( self, @@ -34,6 +34,7 @@ class DiagramFigure(Renderable, RequiresNumbering): ) self._number = None self._listing_number = None + self.landscape = bool(caption_info and caption_info.landscape) if caption_info and caption_info.unique_name: self.unique_name = caption_info.unique_name @@ -56,8 +57,14 @@ class DiagramFigure(Renderable, RequiresNumbering): def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \ -> Generator[RenderedInfo, None, None]: - png_path = render_diagram(self._lang, self._source) - image = Image(self._parent, png_path, self._caption_info) + result = render_diagram(self._lang, self._source) + image = Image( + self._parent, + result.png_path, + self._caption_info, + svg_path=result.svg_path, + pixel_scale=result.pixel_scale, + ) if self._number is not None: image.set_number(self._number) yield from image.render(previous_rendered, layout_state) diff --git a/md2gost/renderable/heading.py b/md2gost/renderable/heading.py index 629c498..aa464f3 100644 --- a/md2gost/renderable/heading.py +++ b/md2gost/renderable/heading.py @@ -2,7 +2,6 @@ from copy import copy from typing import Generator import re -from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.text.paragraph import Paragraph as DocxParagraph from docx.shared import Parented, Length @@ -35,7 +34,8 @@ class Heading(Paragraph): if not numbered: self._remove_numbering() - self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + # Alignment decided later in Renderer: only СОДЕРЖАНИЕ / СПИСОК centered. + # ВВЕДЕНИЕ / ЗАКЛЮЧЕНИЕ / ПРИЛОЖЕНИЯ stay left like Heading 1 (1.25 cm indent). elif numbering_mode == "manual": # Digits already in markdown text — kill Word list numbering to avoid "1 1 …" self._remove_numbering() diff --git a/md2gost/renderable/image.py b/md2gost/renderable/image.py index 2c1db73..e1e0252 100644 --- a/md2gost/renderable/image.py +++ b/md2gost/renderable/image.py @@ -7,20 +7,32 @@ import os.path import requests from docx.enum.text import WD_PARAGRAPH_ALIGNMENT -from docx.shared import Parented, Length +from docx.shared import Parented, Length, Mm from docx.text.paragraph import Paragraph from .caption import Caption, CaptionInfo from .renderable import Renderable from .requires_numbering import RequiresNumbering +from ..docx_svg import attach_svg_blip from ..layout_tracker import LayoutState from ..rendered_info import RenderedInfo from ..sub_renderable import SubRenderable from ..util import create_element +# Leave room under the figure so «Рисунок N — …» stays on the same page. +_CAPTION_RESERVE = Mm(12) + class Image(Renderable, RequiresNumbering): - def __init__(self, parent: Parented, path: str, caption_info: CaptionInfo | None = None): + def __init__( + self, + parent: Parented, + path: str, + caption_info: CaptionInfo | None = None, + svg_path: str | None = None, + *, + pixel_scale: float = 1.0, + ): super().__init__("Рисунок") self._parent = parent self._caption_info = caption_info @@ -31,6 +43,8 @@ class Image(Renderable, RequiresNumbering): self._docx_paragraph.paragraph_format.line_spacing = 1 self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER self._invalid = False + self._native_width: Length | None = None + self._native_height: Length | None = None run = self._docx_paragraph.add_run() @@ -47,36 +61,82 @@ class Image(Renderable, RequiresNumbering): except FileNotFoundError: logging.warning(f"Invalid image path: {path}, skipping...") self._invalid = True + self._image = None + + # High-res PlantUML/Kroki PNG: keep on-page size as if scale were 1. + if not self._invalid and pixel_scale and pixel_scale > 1: + self._image.width = Length(int(self._image.width / pixel_scale)) + self._image.height = Length(int(self._image.height / pixel_scale)) + + if not self._invalid: + self._native_width = Length(int(self._image.width)) + self._native_height = Length(int(self._image.height)) + + if not self._invalid and svg_path: + try: + attach_svg_blip(run, self._image, svg_path) + except Exception as exc: + logging.warning("SVG blip attach failed (%s): %s", svg_path, exc) self._number = None + self.landscape = bool(caption_info and caption_info.landscape) if caption_info and caption_info.unique_name: self.unique_name = caption_info.unique_name + # Keep figure + caption together across Word pagination. + if not self._invalid and caption_info is not None: + self._docx_paragraph.paragraph_format.keep_with_next = True + def set_number(self, number: str): self._number = number + def _reset_native_size(self) -> None: + if self._image is None or self._native_width is None or self._native_height is None: + return + self._image.width = Length(int(self._native_width)) + self._image.height = Length(int(self._native_height)) + def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\ -> Generator[RenderedInfo | SubRenderable, None, None]: if self._invalid: yield from [] return + # Re-fit from native size each time (Paragraph may have measured us earlier). + self._reset_native_size() + + has_caption = self._caption_info is not None + caption_reserve = _CAPTION_RESERVE if has_caption else Length(0) + max_w = layout_state.max_width + max_h = Length(max(0, int(layout_state.max_height) - int(caption_reserve))) + # limit width - if self._image.width > layout_state.max_width: + if self._image.width > max_w: height_by_width = self._image.height / self._image.width - self._image.width = layout_state.max_width + self._image.width = max_w self._image.height = Length(self._image.width * height_by_width) - # limit height - if self._image.height > layout_state.max_height: + # limit height (leave room for caption on the same page) + if self._image.height > max_h: width_by_height = self._image.width / self._image.height - self._image.height = layout_state.max_height + self._image.height = max_h self._image.width = Length(self._image.height * width_by_height) height = self._image.height - - if layout_state.remaining_page_height < height: - height += layout_state.remaining_page_height + need = Length(int(height) + int(caption_reserve)) + remaining = layout_state.remaining_page_height + if remaining < need: + if self.landscape: + # Already on a fresh landscape section — shrink instead of soft page-break + # (soft break + section break = empty landscape page before the figure). + avail = Length(max(0, int(remaining) - int(caption_reserve))) + if avail > 0 and self._image.height > avail: + ratio = int(avail) / int(self._image.height) + self._image.height = avail + self._image.width = Length(int(self._image.width * ratio)) + height = self._image.height + else: + height = Length(int(height) + int(remaining)) yield (rendered_image := RenderedInfo(self._docx_paragraph, Length(height))) diff --git a/md2gost/renderable/list.py b/md2gost/renderable/list.py index dd626ea..ad520d0 100644 --- a/md2gost/renderable/list.py +++ b/md2gost/renderable/list.py @@ -85,6 +85,10 @@ class List(Renderable): if self._paragraphs: self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after + if getattr(self, "_space_before_mm6", False) and self._paragraphs: + from docx.shared import Mm + self._paragraphs[0]._docx_paragraph.paragraph_format.space_before = Mm(6) + for paragraph in self._paragraphs: for x in paragraph.render(previous_rendered, copy(layout_state)): layout_state.add_height(x.height) diff --git a/md2gost/renderable/listing.py b/md2gost/renderable/listing.py index 11b3a27..8cc187c 100644 --- a/md2gost/renderable/listing.py +++ b/md2gost/renderable/listing.py @@ -2,23 +2,25 @@ from copy import copy import os from typing import Generator, Callable -from docx.oxml import CT_Tbl from docx.shared import Length, Pt, RGBColor, Twips -from docx.table import Table from pygments import highlight from pygments.formatter import Formatter from pygments.lexers import get_lexer_by_name from .caption import Caption, CaptionInfo +from .page_break import PageBreak from .paragraph import Paragraph from .renderable import Renderable from .requires_numbering import RequiresNumbering -from ..docx_elements import create_table, _twips +from ..docx_elements import create_table, create_table_row, create_table_cell, set_table_box_borders, _twips from ..layout_tracker import LayoutState +from ..profiles import DEFAULT_LISTING_CONTINUATION, LISTING_CONTINUATION_MODES from ..rendered_info import RenderedInfo from ..sub_renderable import SubRenderable +_WORD_PAGED_MODES = frozenset({"off", "soft", "word"}) + class DocxParagraphPygmentsFormatter(Formatter): def __init__(self, paragraphs: list[Paragraph], creator: Callable[[], Paragraph], **options): @@ -55,13 +57,14 @@ class Listing(Renderable, RequiresNumbering): self._caption_info = caption_info self._language = language self._parent = parent + self._continuation_mode = DEFAULT_LISTING_CONTINUATION self.paragraphs: list[Paragraph] = [] self._number = None if caption_info and caption_info.unique_name: self.unique_name = caption_info.unique_name def _create_table(self, parent, width: Length): - # todo: style inheritance + # Kept for tests / callers that still expect the helper; render uses multi-row. left_margin = Twips(int( parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[ "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"])) @@ -94,6 +97,51 @@ class Listing(Renderable, RequiresNumbering): def set_number(self, number: str): self._number = number + def set_continuation_mode(self, mode: str) -> None: + if mode not in LISTING_CONTINUATION_MODES: + raise ValueError( + f"listing continuation must be one of {LISTING_CONTINUATION_MODES}, got {mode!r}" + ) + self._continuation_mode = mode + + def _should_split_fragment( + self, line_height, layout_state: LayoutState, lines_in_fragment: int + ) -> bool: + if self._continuation_mode in _WORD_PAGED_MODES: + return False + if lines_in_fragment == 0: + return False + return line_height > layout_state.remaining_page_height + + def _make_continuation_paragraph(self) -> Paragraph: + continuation_paragraph = Paragraph(self._parent) + continuation_paragraph.add_run(f"Продолжение Листинга {self._number}") + continuation_paragraph.style = "Caption Listing" + continuation_paragraph.first_line_indent = 0 + return continuation_paragraph + + def _emit_page_break_and_optional_caption( + self, layout_state: LayoutState + ) -> Generator[RenderedInfo, None, None]: + mode = self._continuation_mode + if mode == "legacy": + continuation_paragraph = self._make_continuation_paragraph() + continuation_paragraph.page_break_before = True + info = next(continuation_paragraph.render(None, copy(layout_state))) + layout_state.add_height(info.height) + yield info + return + + page_break_info = next(PageBreak(self._parent).render(None, layout_state)) + layout_state.add_height(page_break_info.height) + yield page_break_info + + continuation_paragraph = self._make_continuation_paragraph() + continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True + info = next(continuation_paragraph.render(None, copy(layout_state))) + layout_state.add_height(info.height) + yield info + def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\ -> Generator[RenderedInfo | SubRenderable, None, None]: caption_rendered_infos = list( @@ -103,54 +151,65 @@ class Listing(Renderable, RequiresNumbering): layout_state.add_height(sum([info.height for info in caption_rendered_infos])) yield from caption_rendered_infos - table = self._create_table(self._parent, layout_state.max_width) + # One code line = one table row so Word can Split at real page breaks (mode word). + # Outer box only — no grid lines between lines (looks like text in a frame). + table = create_table(self._parent, 0, 1, self._listing_width(layout_state)) + set_table_box_borders(table) previous = None table_height = Pt(1) # table borders, 4 eights of point for each border + lines_in_fragment = 0 + col_w = self._listing_width(layout_state) - # if first line doesn't fit move listing to the next page - paragraph_layout_state = copy(layout_state) - paragraph_layout_state.max_width -= LISTING_OFFSET - paragraph_rendered_info = next(self.paragraphs[0].render(previous, paragraph_layout_state)) - if paragraph_rendered_info.height + table_height > layout_state.remaining_page_height: - table_height += layout_state.remaining_page_height - layout_state.add_height(layout_state.remaining_page_height) + # legacy/caption: if first line doesn't fit, burn the rest of the page + if self.paragraphs and self._continuation_mode not in _WORD_PAGED_MODES: + paragraph_layout_state = copy(layout_state) + paragraph_layout_state.max_width -= LISTING_OFFSET + first = next(self.paragraphs[0].render(previous, paragraph_layout_state)) + if first.height + table_height > layout_state.remaining_page_height: + table_height += layout_state.remaining_page_height + layout_state.add_height(layout_state.remaining_page_height) for paragraph in self.paragraphs: paragraph_layout_state = copy(layout_state) paragraph_layout_state.max_width -= LISTING_OFFSET paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state)) - if paragraph_rendered_info.height > layout_state.remaining_page_height: # todo add before after - table_rendered_info = RenderedInfo(table, table_height) - yield table_rendered_info - - table_height = Pt(1) # table borders, 4 eights of point for each border - - continuation_paragraph = Paragraph(self._parent) - continuation_paragraph.add_run(f"Продолжение Листинга {self._number}") - continuation_paragraph.style = "Caption Listing" - continuation_paragraph.first_line_indent = 0 - continuation_paragraph.page_break_before = True - - continuation_rendered_info = next( - continuation_paragraph.render(None, copy(layout_state))) - - layout_state.add_height(continuation_rendered_info.height) - yield continuation_rendered_info - - table = self._create_table(self._parent, layout_state.max_width) + if self._should_split_fragment( + paragraph_rendered_info.height, layout_state, lines_in_fragment + ): + yield RenderedInfo(table, table_height) + yield from self._emit_page_break_and_optional_caption(layout_state) + table_height = Pt(1) + table = create_table(self._parent, 0, 1, col_w) + set_table_box_borders(table) previous = None + lines_in_fragment = 0 paragraph_layout_state = copy(layout_state) paragraph_layout_state.max_width -= LISTING_OFFSET paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state)) - table._cells[0]._element.append(paragraph_rendered_info.docx_element._element) + row = create_table_row(table, header=False) + cell = create_table_cell(row, col_w) + cell._element.append(paragraph_rendered_info.docx_element._element) + row._element.append(cell._element) + table._element.append(row._element) + layout_state.add_height(paragraph_rendered_info.height) table_height += paragraph_rendered_info.height + lines_in_fragment += 1 previous = paragraph_rendered_info yield RenderedInfo(table, table_height) + + def _listing_width(self, layout_state: LayoutState) -> Length: + left_margin = Twips(int( + self._parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[ + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"])) + right_margin = Twips(int( + self._parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[ + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"])) + return Twips(_twips(layout_state.max_width) + _twips(left_margin) + _twips(right_margin)) diff --git a/md2gost/renderable/paragraph.py b/md2gost/renderable/paragraph.py index 7b61008..bdb5049 100644 --- a/md2gost/renderable/paragraph.py +++ b/md2gost/renderable/paragraph.py @@ -89,14 +89,16 @@ class Paragraph(Renderable): return link def add_inline_equation(self, formula: str): - # omml = inline_omml(latex_to_omml(formula)) - # for r in omml.xpath("//m:r", namespaces=omml.nsmap): - # r.append(create_element("w:rPr", [ - # create_element("w:sz", {"w:val": "24"}), - # create_element("w:szCs", {"w:val": "24"}), - # ])) - # self._docx_paragraph._element.append(omml) - self.add_run(formula, is_italic=True) + try: + omml = inline_omml(latex_to_omml(formula)) + for r in omml.xpath("//m:r", namespaces=omml.nsmap): + r.append(create_element("w:rPr", [ + create_element("w:sz", {"w:val": "28"}), + create_element("w:szCs", {"w:val": "28"}), + ])) + self._docx_paragraph._element.append(omml) + except Exception: + self.add_run(formula, is_italic=True) @property def page_break_before(self) -> bool: @@ -170,6 +172,12 @@ class Paragraph(Renderable): images = iter(self._images) for image in images: + # +landscape: section break handles the new page. Do NOT measure against + # portrait remaining (that queues add_to_new_page and leaves an empty page). + if getattr(image, "landscape", False): + yield SubRenderable(image, False) + continue + rendered_image = list(image.render(previous_rendered, copy(layout_state))) rendered_image_height = sum([x.height for x in rendered_image]) if rendered_image: diff --git a/md2gost/renderable/table.py b/md2gost/renderable/table.py index 074afb2..0b7d8ad 100644 --- a/md2gost/renderable/table.py +++ b/md2gost/renderable/table.py @@ -18,8 +18,9 @@ CELL_OFFSET = Pt(9) - Twips(108 * 2) # Slack only for modes that fragment by our height estimate (legacy/caption). ROW_HEIGHT_SLACK = Pt(4) -# Modes that do NOT cut the table into fragments — Word owns page breaks. -_WORD_PAGED_MODES = frozenset({"off", "soft"}) +# Modes that do NOT cut the table into fragments — Word owns page breaks +# (word: post-split via COM after save). +_WORD_PAGED_MODES = frozenset({"off", "soft", "word"}) class Table(Renderable, RequiresNumbering): @@ -35,6 +36,7 @@ class Table(Renderable, RequiresNumbering): self._cell_margin_lr = left_margin + right_margin self._number = "?" + self.landscape = bool(caption_info and caption_info.landscape) if caption_info and caption_info.unique_name: self.unique_name = caption_info.unique_name @@ -144,7 +146,7 @@ class Table(Renderable, RequiresNumbering): def _make_continuation_paragraph(self) -> Paragraph: continuation_paragraph = Paragraph(self._parent) continuation_paragraph.add_run(f"Продолжение Таблицы {self._number}") - continuation_paragraph.style = "Caption Table" + continuation_paragraph.style = "Название таблицы" continuation_paragraph.first_line_indent = 0 return continuation_paragraph diff --git a/md2gost/renderable/toc.py b/md2gost/renderable/toc.py index 8334767..e481804 100644 --- a/md2gost/renderable/toc.py +++ b/md2gost/renderable/toc.py @@ -116,7 +116,12 @@ class ToC(Renderable): continue display = title.strip() - if display.upper() in SPECIAL_TITLES or display.upper().startswith("ПРИЛОЖЕНИЕ"): + # Special sections and numbered H1 → uppercase in TOC (method guide) + if ( + display.upper() in SPECIAL_TITLES + or display.upper().startswith("ПРИЛОЖЕНИЕ") + or (level == 1 and numbered) + ): display = display.upper() p.add_run(" " * (level - 1)) diff --git a/md2gost/renderable_factory.py b/md2gost/renderable_factory.py index eae97f9..1ceeac9 100644 --- a/md2gost/renderable_factory.py +++ b/md2gost/renderable_factory.py @@ -15,12 +15,13 @@ from .renderable.list import List from .renderable.toc import ToC from .renderable.diagram import DiagramFigure from .label_pass import resolve_reference -from .diagram_renderer import DIAGRAM_LANGS +from .diagram_renderer import is_diagram_lang class RenderableFactory: - def __init__(self, parent: Parented): + def __init__(self, parent: Parented, hr_pagebreak: bool = False): self._parent = parent + self._hr_pagebreak = hr_pagebreak @singledispatchmethod def create(self, marko_element: extended_markdown.BlockElement, @@ -43,7 +44,12 @@ class RenderableFactory: elif isinstance(child, extended_markdown.CodeSpan): paragraph_or_link.add_run(child.children, is_italic=True) elif isinstance(child, extended_markdown.Image): - caption = CaptionInfo(child.unique_name, child.title) + caption = CaptionInfo( + child.unique_name, + child.title, + getattr(child, "with_listing", False), + getattr(child, "landscape", False), + ) paragraph_or_link.add_image(child.dest, caption) elif isinstance(child, extended_markdown.LineBreak): pass @@ -82,7 +88,7 @@ class RenderableFactory: def _(self, marko_code_block: extended_markdown.FencedCode, caption_info: CaptionInfo): lang = (marko_code_block.lang or "").strip().lower() source = marko_code_block.children[0].children - if lang in DIAGRAM_LANGS: + if is_diagram_lang(lang): return DiagramFigure( self._parent, lang, @@ -98,7 +104,7 @@ class RenderableFactory: def _(self, marko_code_block: extended_markdown.CodeBlock, caption_info: CaptionInfo): lang = (getattr(marko_code_block, "lang", "") or "").strip().lower() source = marko_code_block.children[0].children - if lang in DIAGRAM_LANGS: + if is_diagram_lang(lang): return DiagramFigure( self._parent, lang, @@ -161,3 +167,16 @@ class RenderableFactory: def _(self, marko_toc: extended_markdown.TOC, caption_info: CaptionInfo): toc = ToC(self._parent) return toc + + @create.register + def _(self, marko_hr: extended_markdown.ThematicBreak, caption_info: CaptionInfo): + if self._hr_pagebreak: + from .renderable.page_break import PageBreak + return PageBreak(self._parent) + paragraph = Paragraph(self._parent) + paragraph.add_run( + "ThematicBreak is not supported", + color=RGBColor.from_string("ff0000"), + ) + logging.warning("ThematicBreak is not supported") + return paragraph diff --git a/md2gost/renderer.py b/md2gost/renderer.py index cbea6c9..98ac382 100644 --- a/md2gost/renderer.py +++ b/md2gost/renderer.py @@ -3,18 +3,24 @@ from itertools import chain import re from docx.document import Document -from docx.shared import Length, Cm, Parented, Pt, Mm +from docx.shared import Length, Parented, Mm, Cm from docx.enum.text import WD_PARAGRAPH_ALIGNMENT +from docx.enum.section import WD_SECTION from .numberer import Numberer, APPENDIX_LETTERS from .renderable import Renderable from .renderable.requires_numbering import RequiresNumbering from .renderable.heading import Heading from .renderable.equation import Equation +from .renderable.paragraph import Paragraph from .rendered_info import RenderedInfo from .sub_renderable import SubRenderable -from .util import create_element from .layout_tracker import LayoutTracker +from .page_geometry import ( + apply_centered_page_footer, + apply_section_geometry, + content_size, +) if TYPE_CHECKING: from .debugger import Debugger @@ -22,7 +28,12 @@ if TYPE_CHECKING: BOTTOM_MARGIN = Mm(20) APPENDIX_RE = re.compile( - r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-Z])\b", + r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])\b", + re.IGNORECASE, +) +# Lettered appendix title: «Приложение А» or «Приложение А Название» +APPENDIX_ITEM_RE = re.compile( + r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$", re.IGNORECASE, ) SPECIAL_CENTER = { @@ -48,13 +59,9 @@ class Renderer: self._appendix_index = 0 self._after_toc = False self._body_section_started = False + self._landscape_depth = 0 - max_height = (document.sections[0].page_height - - document.sections[0].top_margin - - BOTTOM_MARGIN) - max_width = (self._document.sections[0].page_width - - self._document.sections[0].left_margin - - self._document.sections[0].right_margin) + max_height, max_width = content_size(landscape=False) self._layout_tracker = LayoutTracker(max_height, max_width) # Front-matter section: no page numbers (титул / задание / содержание) @@ -62,6 +69,8 @@ class Renderer: self.previous_rendered = None self._to_new_page: list[Renderable] = [] + self._after_current: list[Renderable] = [] + self._need_space_after_object = False @staticmethod def _clear_footer(section): @@ -77,27 +86,39 @@ class Renderer: if self._body_section_started: return self._body_section_started = True - # Continuous/new page section break is inserted via last paragraph sectPr; - # python-docx: add_section creates new section. - from docx.enum.section import WD_ORIENT, WD_SECTION new_section = self._document.add_section(WD_SECTION.NEW_PAGE) - new_section.page_width = self._document.sections[0].page_width - new_section.page_height = self._document.sections[0].page_height - new_section.left_margin = Mm(30) - new_section.right_margin = Mm(10) - new_section.top_margin = Mm(20) - new_section.bottom_margin = Mm(20) + apply_section_geometry(new_section, landscape=False) + apply_centered_page_footer(new_section) - paragraph = new_section.footer.paragraphs[0] - paragraph.paragraph_format.first_line_indent = 0 - paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER - run = paragraph.add_run() - run.font.name = "Times New Roman" - run.font.size = Pt(12) - # PAGE field - paragraph._p.append(create_element("w:fldSimple", { - "w:instr": "PAGE \\* MERGEFORMAT" - })) + def _enter_landscape(self) -> None: + if self._landscape_depth > 0: + self._landscape_depth += 1 + return + self._flush_to_new_screen() + if self._after_toc: + self._ensure_body_section_with_page_numbers() + elif not self._body_section_started: + # No TOC — still need page numbers on subsequent sections. + self._body_section_started = True + apply_centered_page_footer(self._document.sections[0]) + section = self._document.add_section(WD_SECTION.NEW_PAGE) + apply_section_geometry(section, landscape=True) + apply_centered_page_footer(section) + max_height, max_width = content_size(landscape=True) + self._layout_tracker.set_page_size(max_height, max_width) + self._landscape_depth = 1 + + def _exit_landscape(self) -> None: + if self._landscape_depth <= 0: + return + self._landscape_depth -= 1 + if self._landscape_depth > 0: + return + section = self._document.add_section(WD_SECTION.NEW_PAGE) + apply_section_geometry(section, landscape=False) + apply_centered_page_footer(section) + max_height, max_width = content_size(landscape=False) + self._layout_tracker.set_page_size(max_height, max_width) def process(self, renderables: list[Renderable]): for i in range(len(renderables)): @@ -107,46 +128,84 @@ class Renderer: # If document had no TOC, still add page numbers to the only section if not self._body_section_started: section = self._document.sections[0] - paragraph = section.footer.paragraphs[0] - paragraph.paragraph_format.first_line_indent = 0 - paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER - paragraph._p.append(create_element("w:fldSimple", { - "w:instr": "PAGE \\* MERGEFORMAT" - })) + apply_centered_page_footer(section) if self._debugger: self._debugger.after_rendered() + @staticmethod + def _rewrite_heading_text(heading: Heading, text: str) -> None: + runs = heading._docx_paragraph.runs + if runs: + runs[0].text = text + for r in runs[1:]: + r.text = "" + else: + heading.add_run(text) + + def _make_appendix_title_paragraph(self, title: str) -> Paragraph: + """Title line under «Приложение А»: Normal, centered, no first-line indent.""" + p = Paragraph(self._document._body) + p.add_run(title) + p.style = "Normal" + p.first_line_indent = Cm(0) + pf = p._docx_paragraph.paragraph_format + pf.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + pf.left_indent = Cm(0) + pf.keep_with_next = True + return p + def _handle_heading(self, heading: Heading): text = (heading.text or "").strip() - upper = text.upper() + upper = text.upper().replace("Ё", "Е") if heading.level == 1 and heading.is_numbered: self._section_count += 1 self._numberer.enter_section(self._section_count) - # Appendix: «Приложение А» / «ПРИЛОЖЕНИЕ А» - m = APPENDIX_RE.match(upper.replace("Ё", "Е")) - if m or (heading.level <= 3 and upper.startswith("ПРИЛОЖЕНИЕ")): - letter = None - if m: - letter = m.group(1).upper() - else: - parts = upper.split() - if len(parts) >= 2 and parts[1] in APPENDIX_LETTERS: - letter = parts[1] - elif self._appendix_index < len(APPENDIX_LETTERS): - letter = APPENDIX_LETTERS[self._appendix_index] - self._appendix_index += 1 - if letter: + # Letter appendix item: «Приложение А» / «Приложение А Название» + # (not the section header «ПРИЛОЖЕНИЯ») + item = APPENDIX_ITEM_RE.match(upper) if upper.startswith("ПРИЛОЖЕНИЕ ") else None + if item: + letter = item.group(1).upper().replace("Ё", "Е") + title_tail = (item.group(2) or "").strip() + if letter in APPENDIX_LETTERS: + self._numberer.enter_appendix(letter) + elif self._appendix_index < len(APPENDIX_LETTERS): + letter = APPENDIX_LETTERS[self._appendix_index] + self._appendix_index += 1 self._numberer.enter_appendix(letter) - # Center special unnumbered headings that must be centered (already centered if unnumbered) - if upper in SPECIAL_CENTER or upper.startswith("ПРИЛОЖЕНИЕ"): + # Style as Heading 3, centered, new page (method guide §6) + heading.style = "Heading 3" + heading._level = 3 + heading._remove_numbering() heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + heading._docx_paragraph.paragraph_format.left_indent = Cm(0) + heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0) + heading.page_break_before = True + heading._docx_paragraph.paragraph_format.keep_with_next = True - # СОДЕРЖАНИЕ / СПИСОК — center even if somehow numbered - if upper in ("СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"): + orig = (heading.text or "").strip() + m_orig = re.match(r"(?i)^Приложение\s+([А-ЯA-ZЁ])\b", orig) + letter_display = m_orig.group(1).upper().replace("Ё", "Е") if m_orig else letter + self._rewrite_heading_text(heading, f"Приложение {letter_display}") + + if title_tail: + m_tail = re.match(r"(?i)^Приложение\s+[А-ЯA-ZЁ]\s+(.+)$", orig) + title_text = m_tail.group(1).strip() if m_tail else title_tail + self._after_current.append(self._make_appendix_title_paragraph(title_text)) + return + + # Section «ПРИЛОЖЕНИЯ» / bare «ПРИЛОЖЕНИЕ» — left like H1, not centered + if upper in ("ПРИЛОЖЕНИЯ", "ПРИЛОЖЕНИЕ"): + heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT + return + + # Only СОДЕРЖАНИЕ / СПИСОК … are centered (method guide p. 8) + if upper in SPECIAL_CENTER: heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + heading._docx_paragraph.paragraph_format.left_indent = Cm(0) + heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0) # Native Word TOC picks up Heading 1–3; «СОДЕРЖАНИЕ» must not list itself if upper == "СОДЕРЖАНИЕ": @@ -159,69 +218,113 @@ class Renderer: # After ToC page-break renderable we open body section — detected via ToC's PageBreak from .renderable.toc import ToC from .renderable.page_break import PageBreak + from .renderable.table import Table + from .renderable.listing import Listing + from .renderable.diagram import DiagramFigure + from .renderable.list import List as RList + if isinstance(renderable, PageBreak) and self._after_toc: self._ensure_body_section_with_page_numbers() if isinstance(renderable, ToC): self._after_toc = True - if not self._skip_numbering and isinstance(renderable, RequiresNumbering): - if isinstance(renderable, Equation): - label = renderable.unique_name - if renderable.needs_numbering or (label and label in self._numbered_equations): - renderable.enable_numbering() - number = self._numberer.next_number( - renderable.numbering_category, label) - renderable.set_number(number) - else: - number = self._numberer.next_number( - renderable.numbering_category, - getattr(renderable, "unique_name", None), - ) - renderable.set_number(number) + # Space 6 mm before next body paragraph after table/listing + if self._need_space_after_object: + self._need_space_after_object = False + if isinstance(renderable, (Paragraph, RList)) and not isinstance(renderable, Heading): + if isinstance(renderable, RList): + # Apply to first list item when rendered — mark on list + setattr(renderable, "_space_before_mm6", True) + else: + renderable._docx_paragraph.paragraph_format.space_before = Mm(6) - infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state) + wants_landscape = bool(getattr(renderable, "landscape", False)) + deferred_listing = None + if wants_landscape and isinstance(renderable, DiagramFigure) and renderable.listing is not None: + # Method guide: landscape pages are for figures/tables only — listing after + deferred_listing = renderable.listing + renderable.listing = None + + if wants_landscape: + self._enter_landscape() try: - first = next(infos) - if isinstance(first, RenderedInfo) and first.height \ - >= self._layout_tracker.current_state.remaining_page_height: - self._flush_to_new_screen() - infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state) - else: - infos = chain([first], infos) - except StopIteration: - pass - - for info in infos: - if isinstance(info, SubRenderable): - if info.add_to_new_page: - self._to_new_page.append(info.renderable) + if not self._skip_numbering and isinstance(renderable, RequiresNumbering): + if isinstance(renderable, Equation): + label = renderable.unique_name + if renderable.needs_numbering or (label and label in self._numbered_equations): + renderable.enable_numbering() + number = self._numberer.next_number( + renderable.numbering_category, label) + renderable.set_number(number) else: - self.render(info.renderable) - else: - self._add(info.docx_element, info.height) - self.previous_rendered = info + number = self._numberer.next_number( + renderable.numbering_category, + getattr(renderable, "unique_name", None), + ) + renderable.set_number(number) + + infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state) + + try: + first = next(infos) + if isinstance(first, RenderedInfo) and first.height \ + >= self._layout_tracker.current_state.remaining_page_height: + self._flush_to_new_screen() + infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state) + else: + infos = chain([first], infos) + except StopIteration: + pass + + for info in infos: + if isinstance(info, SubRenderable): + if info.add_to_new_page: + self._to_new_page.append(info.renderable) + else: + self.render(info.renderable) + else: + self._add(info.docx_element, info.height) + self.previous_rendered = info + finally: + if wants_landscape: + self._exit_landscape() + + # Appendix title line queued by _handle_heading + while self._after_current: + extra = self._after_current.pop(0) + self.render(extra) + + if deferred_listing is not None: + self.render(deferred_listing) + + if isinstance(renderable, (Table, Listing)): + self._need_space_after_object = True + elif isinstance(renderable, DiagramFigure) and deferred_listing is None and renderable.listing is None: + # Diagram figure alone — no table/listing spacing needed after image caption + pass def _flush_to_new_screen(self): while self._to_new_page: renderable = self._to_new_page.pop(0) - if not self._skip_numbering and isinstance(renderable, RequiresNumbering): - number = self._numberer.next_number( - renderable.numbering_category, - getattr(renderable, "unique_name", None), - ) - renderable.set_number(number) - for info_ in renderable.render(self.previous_rendered, self._layout_tracker.current_state): - if isinstance(info_, SubRenderable): - continue - self._add(info_.docx_element, info_.height) - self.previous_rendered = info_ + self.render(renderable) def _add(self, element: Parented, height: Length): - self._document._body._element.append( - element._element - ) + # MUST insert before the body-level w:sectPr. append() puts content after it, + # so section breaks from +landscape never wrap the figure (Word shows portrait). + body = self._document._body._element + el = element._element + from docx.oxml.ns import qn + sect_pr = None + for child in body: + if child.tag == qn("w:sectPr"): + sect_pr = child + break + if sect_pr is not None: + sect_pr.addprevious(el) + else: + body.append(el) self._layout_tracker.add_height(height) if self._debugger: diff --git a/md2gost/styles.py b/md2gost/styles.py index 910030e..8d42ef3 100644 --- a/md2gost/styles.py +++ b/md2gost/styles.py @@ -173,13 +173,11 @@ def apply_document_styles(document: Document, style_preset: str = "mirea") -> No def _apply_common_page_and_body(document: Document) -> None: + from .page_geometry import apply_section_geometry, is_landscape_section + for section in document.sections: - section.page_width = Mm(210) - section.page_height = Mm(297) - section.left_margin = Mm(30) - section.right_margin = Mm(10) - section.top_margin = Mm(20) - section.bottom_margin = Mm(20) + # Do not wipe landscape sections created for +landscape figures/tables. + apply_section_geometry(section, landscape=is_landscape_section(section)) _fix_toc_tab_stops(document) @@ -209,7 +207,7 @@ def _apply_common_captions_and_misc(document: Document) -> None: cpf.line_spacing_rule = WD_LINE_SPACING.SINGLE cpf.widow_control = True - # --- Caption Table: 12pt italic, left, above table --- + # --- Caption Table (legacy EN name) + «Название таблицы» (основной) --- caption_tbl = _ensure_style(document, "Caption Table", "Caption") _set_run_font(caption_tbl, "Times New Roman", 12, italic=True) tpf = caption_tbl.paragraph_format @@ -219,8 +217,22 @@ def _apply_common_captions_and_misc(document: Document) -> None: tpf.space_before = Mm(6) tpf.space_after = Mm(0) tpf.line_spacing_rule = WD_LINE_SPACING.SINGLE + tpf.keep_with_next = True tpf.widow_control = True + # ГОСТ-имя стиля подписи таблицы (те же параметры, что Caption Table) + caption_tbl_ru = _ensure_style(document, "Название таблицы", "Caption Table") + _set_run_font(caption_tbl_ru, "Times New Roman", 12, italic=True) + tpf_ru = caption_tbl_ru.paragraph_format + tpf_ru.alignment = WD_ALIGN_PARAGRAPH.LEFT + tpf_ru.first_line_indent = Cm(0) + tpf_ru.left_indent = Cm(0) + tpf_ru.space_before = Mm(6) + tpf_ru.space_after = Mm(0) + tpf_ru.line_spacing_rule = WD_LINE_SPACING.SINGLE + tpf_ru.keep_with_next = True + tpf_ru.widow_control = True + # --- Caption Listing (как таблицы) --- caption_lst = _ensure_style(document, "Caption Listing", "Caption") _set_run_font(caption_lst, "Times New Roman", 12, italic=True) @@ -256,7 +268,9 @@ def _apply_common_captions_and_misc(document: Document) -> None: table_text = _ensure_style(document, "Table Text", "Normal") _set_run_font(table_text, "Times New Roman", 12) ttf = table_text.paragraph_format + ttf.alignment = WD_ALIGN_PARAGRAPH.LEFT ttf.first_line_indent = Cm(0) + ttf.left_indent = Cm(0) ttf.space_before = Mm(0) ttf.space_after = Mm(0) ttf.line_spacing_rule = WD_LINE_SPACING.SINGLE @@ -276,12 +290,28 @@ def _apply_common_captions_and_misc(document: Document) -> None: bhpf = biblio_h.paragraph_format bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER bhpf.first_line_indent = Cm(0) - bhpf.left_indent = Cm(0) + bhpf.left_indent = Cm(1.25) # табл. 5.1 bhpf.space_before = Mm(6) bhpf.space_after = Mm(6) bhpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE bhpf.keep_with_next = True + # TOC styles: TNR 14, 1.5, no bold, no first-line indent; toc 1 = ALL CAPS + for toc_name, all_caps in (("toc 1", True), ("toc 2", False), ("toc 3", False)): + try: + toc_style = document.styles[toc_name] + except KeyError: + toc_style = _ensure_style(document, toc_name, "Normal") + _set_run_font(toc_style, "Times New Roman", 14, bold=False) + toc_style.font.all_caps = all_caps + toc_style.font.bold = False + tpf_toc = toc_style.paragraph_format + tpf_toc.alignment = WD_ALIGN_PARAGRAPH.LEFT + tpf_toc.first_line_indent = Cm(0) + tpf_toc.space_before = Pt(0) + tpf_toc.space_after = Pt(0) + tpf_toc.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE + try: footer_style = document.styles["Footer"] _set_run_font(footer_style, "Times New Roman", 12) @@ -301,4 +331,6 @@ def _apply_common_captions_and_misc(document: Document) -> None: after = _ensure_style(document, "Space After Table", "Normal") apf = after.paragraph_format apf.space_before = Mm(6) + apf.space_after = Pt(0) apf.first_line_indent = Cm(1.25) + apf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE diff --git a/md2gost/vendor/.gitkeep b/md2gost/vendor/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/md2gost/word_fix.py b/md2gost/word_fix.py new file mode 100644 index 0000000..e52f669 --- /dev/null +++ b/md2gost/word_fix.py @@ -0,0 +1,525 @@ +"""Post-process DOCX in Word: split tables/listings at real page breaks + «Продолжение…». + +Requires Windows + Microsoft Word + pywin32. Pure helpers below are unit-testable +without Word. +""" + +from __future__ import annotations + +import os +import re +import sys +from dataclasses import dataclass, field + +# wdActiveEndPageNumber +_WD_ACTIVE_END_PAGE_NUMBER = 3 + +_CAPTION_RE = re.compile( + r"^(?:Продолжение\s+)?" + r"(?PТаблица|Таблицы|Листинг|Листинга)\s+" + r"(?P[\d.]+)", + re.IGNORECASE, +) +_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE) + +MAX_PASSES = 5 + + +@dataclass +class CaptionInfo: + kind: str # "table" | "listing" + number: str + is_continuation: bool = False + + +@dataclass +class FixResult: + ok: bool + splits: int = 0 + skipped: int = 0 + message: str = "" + details: list[str] = field(default_factory=list) + + +def parse_caption_text(text: str) -> CaptionInfo | None: + """Parse «Таблица 2.1 — …» / «Продолжение Таблицы 2.1» / «Листинг 1 …».""" + raw = (text or "").replace("\r", "").replace("\x07", "").strip() + if not raw: + return None + first = raw.split("\n", 1)[0].strip() + m = _CAPTION_RE.match(first) + if not m: + return None + kind_raw = m.group("kind").lower() + if kind_raw.startswith("табл"): + kind = "table" + elif kind_raw.startswith("лист"): + kind = "listing" + else: + return None + return CaptionInfo( + kind=kind, + number=m.group("number"), + is_continuation=bool(_CONTINUATION_RE.match(first)), + ) + + +def find_page_break_row(page_numbers: list[int]) -> int | None: + """ + Return 1-based Word row index where a new page starts. + + page_numbers[i] is the page of row i+1. None if the table does not cross pages. + """ + if len(page_numbers) < 2: + return None + for i in range(1, len(page_numbers)): + if page_numbers[i] > page_numbers[i - 1]: + row_1based = i + 1 + if row_1based <= 1: + return None + return row_1based + return None + + +def continuation_label(kind: str, number: str) -> str: + if kind == "listing": + return f"Продолжение Листинга {number}" + return f"Продолжение Таблицы {number}" + + +def caption_style_name(kind: str) -> str: + return "Caption Listing" if kind == "listing" else "Название таблицы" + + +def fix_continuations( + path: str, + *, + tables: bool = True, + listings: bool = True, + repeat_header: bool = False, +) -> FixResult: + """ + Open DOCX in Word, split cross-page tables/listings, insert continuation captions. + + On missing Word/pywin32 returns ok=False with a reason (caller should not fail convert). + """ + if sys.platform != "win32": + return FixResult( + False, + message="Пост-разрыв таблиц (word): только Windows + Microsoft Word.", + ) + try: + import win32com.client # type: ignore + except ImportError: + return FixResult( + False, + message=( + "Пост-разрыв таблиц пропущен: нужен pywin32 " + "(pip install pywin32) и Microsoft Word." + ), + ) + + abs_path = os.path.abspath(path) + if not os.path.isfile(abs_path): + return FixResult(False, message=f"Файл не найден: {abs_path}") + + word = None + doc = None + splits = 0 + details: list[str] = [] + try: + word = win32com.client.DispatchEx("Word.Application") + word.Visible = False + word.DisplayAlerts = 0 + doc = word.Documents.Open(abs_path, ReadOnly=False) + for _pass in range(MAX_PASSES): + doc.Repaginate() + made = _fix_pass( + doc, + tables=tables, + listings=listings, + repeat_header=repeat_header, + details=details, + ) + if made == 0: + break + splits += made + + skipped = sum(1 for d in details if d.startswith("skip:")) + doc.Save() + msg = ( + f"Пост-разрыв Word: разрезов {splits}" + + (f", пропусков {skipped}" if skipped else "") + + "." + ) + return FixResult(True, splits=splits, skipped=skipped, message=msg, details=details) + except Exception as exc: + skipped = sum(1 for d in details if d.startswith("skip:")) + return FixResult( + False, + splits=splits, + skipped=skipped, + message=f"Пост-разрыв таблиц не удался: {exc}", + details=details, + ) + finally: + if doc is not None: + try: + doc.Close(False) + except Exception: + pass + if word is not None: + try: + word.Quit() + except Exception: + pass + + +def _row_page_numbers(table) -> list[int]: + """Page number at the start of each row (more reliable than end-of-range).""" + pages: list[int] = [] + n = int(table.Rows.Count) + for i in range(1, n + 1): + try: + rng = table.Rows(i).Range + # Collapse to start so a tall row reports where it begins + start = int(rng.Start) + page = int(rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER)) + except Exception: + try: + page = int(table.Rows(i).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER)) + except Exception: + page = pages[-1] if pages else 1 + pages.append(page) + return pages + + +def _paragraph_page_numbers_in_cell(table) -> list[int]: + """Fallback: page of each paragraph in a single-cell table (legacy 1-row listings).""" + try: + if int(table.Rows.Count) != 1: + return [] + cell = table.Cell(1, 1) + paras = cell.Range.Paragraphs + pages: list[int] = [] + for i in range(1, int(paras.Count) + 1): + try: + start = int(paras(i).Range.Start) + page = int(paras(i).Range.Document.Range(start, start).Information( + _WD_ACTIVE_END_PAGE_NUMBER + )) + except Exception: + page = pages[-1] if pages else 1 + pages.append(page) + return pages + except Exception: + return [] + + +def _expand_single_cell_listing_to_rows(table) -> bool: + """ + Convert 1-row listing (many paragraphs in one cell) into one row per paragraph. + Returns True if the table was rewritten. + """ + try: + if int(table.Rows.Count) != 1: + return False + cell = table.Cell(1, 1) + paras = cell.Range.Paragraphs + count = int(paras.Count) + if count <= 1: + return False + # Collect plain texts first (mutating while iterating is unsafe) + texts: list[str] = [] + for i in range(1, count + 1): + t = (paras(i).Range.Text or "").replace("\r", "").replace("\x07", "") + texts.append(t) + # Clear cell, keep first paragraph as first row content + cell.Range.Text = texts[0] if texts else "" + # Add rows for remaining lines + for t in texts[1:]: + row = table.Rows.Add() + row.Cells(1).Range.Text = t + return True + except Exception: + return False + + +def _fix_pass( + doc, + *, + tables: bool, + listings: bool, + repeat_header: bool, + details: list[str], +) -> int: + """One pass over tables (bottom-up). Returns number of splits performed.""" + made = 0 + count = int(doc.Tables.Count) + for ti in range(count, 0, -1): + try: + table = doc.Tables(ti) + except Exception: + details.append(f"skip: table[{ti}] inaccessible") + continue + try: + if int(table.NestingLevel) > 1: + continue + except Exception: + pass + + caption = _caption_before_table(table) + if caption is None: + continue + if caption.kind == "table" and not tables: + continue + if caption.kind == "listing" and not listings: + continue + + # Legacy single-cell listings: expand to rows so Split works + if caption.kind == "listing" and int(table.Rows.Count) == 1: + para_pages = _paragraph_page_numbers_in_cell(table) + if find_page_break_row(para_pages) is not None: + if _expand_single_cell_listing_to_rows(table): + details.append(f"expand: listing {caption.number} → {table.Rows.Count} rows") + doc.Repaginate() + + pages = _row_page_numbers(table) + break_at = find_page_break_row(pages) + if break_at is None: + continue + if break_at > int(table.Rows.Count): + continue + + had_header = False + try: + had_header = bool(table.Rows(1).HeadingFormat) + except Exception: + had_header = False + + try: + table.Split(break_at) + except Exception as exc: + details.append(f"skip: split table[{ti}] row {break_at}: {exc}") + continue + + try: + cont = doc.Tables(ti + 1) + except Exception as exc: + details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}") + made += 1 + continue + + before_cont = _paragraph_text_before_table(cont) + first_line = before_cont.strip().split("\n", 1)[0] if before_cont else "" + if not (first_line and _CONTINUATION_RE.match(first_line)): + try: + _insert_continuation_before(doc, cont, caption) + except Exception as exc: + details.append(f"skip: insert caption after split: {exc}") + + # Open bottom of first fragment (tables only — listings keep a full frame) + if caption.kind == "table": + try: + _clear_table_bottom_border(table) + except Exception as exc: + details.append(f"skip: clear bottom border: {exc}") + + if caption.kind == "listing": + try: + _apply_listing_box_borders(table) + _apply_listing_box_borders(cont) + except Exception as exc: + details.append(f"skip: listing borders: {exc}") + + # Header repeat is opt-in (default off) + if repeat_header and had_header and caption.kind == "table": + try: + _ensure_header_on_continuation(first_table=table, cont_table=cont) + except Exception as exc: + details.append(f"skip: header copy: {exc}") + + made += 1 + details.append(f"split: {caption.kind} {caption.number} @row {break_at}") + + return made + +def _caption_before_table(table) -> CaptionInfo | None: + text = _paragraph_text_before_table(table) + if not text: + return None + return parse_caption_text(text) + + +def _paragraph_text_before_table(table) -> str: + try: + rng = table.Range + if rng.Start <= 1: + return "" + doc = table.Range.Document + prev = doc.Range(rng.Start - 1, rng.Start) + p = prev.Paragraphs(1) + return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip() + except Exception: + return "" + + +def _insert_continuation_before(doc, table, caption: CaptionInfo) -> None: + """Insert caption paragraph *outside* the table (before its start). + + ``InsertBefore`` at ``table.Range.Start`` puts text into the first cell — + Word treats the table start as inside the table. Move one character before + the table, insert a paragraph break, then fill that new paragraph. + """ + label = continuation_label(caption.kind, caption.number) + style = caption_style_name(caption.kind) + + # wdCollapseStart=1, wdCharacter=1 + rng = table.Range.Duplicate + rng.Collapse(1) + start0 = int(table.Range.Start) + if start0 > 0: + rng.Move(1, -1) # land on the paragraph mark before the table + rng.InsertParagraphAfter() + # New empty paragraph sits between previous content and the table. + # Refresh table start — it moved forward by one paragraph mark. + table_start = int(table.Range.Start) + if table_start < 1: + raise RuntimeError("table at document start after insert") + # Paragraph immediately before the table + para = doc.Range(table_start - 1, table_start - 1).Paragraphs(1) + # Write into the paragraph without including the trailing \r that borders the table + text_rng = para.Range.Duplicate + # Exclude final paragraph mark so we don't merge into the table + if int(text_rng.End) > int(text_rng.Start): + text_rng.End = int(text_rng.End) - 1 + text_rng.Text = label + + try: + para.Style = style + except Exception: + # Fallback if RU style missing in older docs + try: + para.Style = "Caption Table" if caption.kind == "table" else "Caption" + except Exception: + pass + try: + para.Range.Font.Italic = True + para.Range.Font.Bold = False + para.Range.Font.Underline = 0 # wdUnderlineNone + para.Range.Font.Name = "Times New Roman" + para.Range.Font.Size = 12 + except Exception: + pass + try: + para.Format.FirstLineIndent = 0 + para.Format.SpaceAfter = 0 + para.Format.SpaceBefore = 6 # pt ≈ Mm(6) for first continuation look + para.Format.KeepWithNext = True + para.Format.Alignment = 0 # wdAlignParagraphLeft + except Exception: + pass + + # Sanity: caption must not live inside a table cell + try: + if int(para.Range.Tables.Count) > 0: + raise RuntimeError("continuation caption landed inside a table") + except AttributeError: + pass + + +# WdBorderType (Word): top=-1, left=-2, bottom=-3, right=-4, insideH=-5, insideV=-6 +_WD_BORDER_TOP = -1 +_WD_BORDER_LEFT = -2 +_WD_BORDER_BOTTOM = -3 +_WD_BORDER_RIGHT = -4 +_WD_BORDER_HORIZONTAL = -5 +_WD_BORDER_VERTICAL = -6 +_WD_LINE_STYLE_NONE = 0 +_WD_LINE_STYLE_SINGLE = 1 + + +def _set_border(table, border_id: int, *, line_style: int, line_width: float = 0.5) -> None: + b = table.Borders(border_id) + b.LineStyle = line_style + if line_style != _WD_LINE_STYLE_NONE: + try: + b.LineWidth = line_width + except Exception: + pass + + +def _clear_table_bottom_border(table) -> None: + _set_border(table, _WD_BORDER_BOTTOM, line_style=_WD_LINE_STYLE_NONE) + + +def _apply_listing_box_borders(table) -> None: + """Outer frame only — no inside H/V (code block look).""" + _set_border(table, _WD_BORDER_TOP, line_style=_WD_LINE_STYLE_SINGLE) + _set_border(table, _WD_BORDER_LEFT, line_style=_WD_LINE_STYLE_SINGLE) + _set_border(table, _WD_BORDER_BOTTOM, line_style=_WD_LINE_STYLE_SINGLE) + _set_border(table, _WD_BORDER_RIGHT, line_style=_WD_LINE_STYLE_SINGLE) + try: + _set_border(table, _WD_BORDER_HORIZONTAL, line_style=_WD_LINE_STYLE_NONE) + _set_border(table, _WD_BORDER_VERTICAL, line_style=_WD_LINE_STYLE_NONE) + except Exception: + pass + + +def _cell_plain_text(cell) -> str: + """Cell text without end-of-cell markers.""" + raw = cell.Range.Text or "" + return raw.replace("\r", "").replace("\x07", "").replace("\a", "").strip("\n") + + +def _ensure_header_on_continuation(*, first_table, cont_table) -> None: + """Prepend a copy of the first fragment's header row onto the continuation table.""" + try: + if int(cont_table.Rows.Count) < 1: + return + if bool(cont_table.Rows(1).HeadingFormat): + return + except Exception: + pass + + try: + hdr = first_table.Rows(1) + n_hdr = int(hdr.Cells.Count) + # Insert empty row above first data row + new_row = cont_table.Rows.Add(BeforeRow=cont_table.Rows(1)) + n_new = int(new_row.Cells.Count) + n = min(n_hdr, n_new) + for ci in range(1, n + 1): + try: + # Prefer FormattedText but strip the cell's terminal markers by + # assigning only the in-cell paragraph text. + src_cell = hdr.Cells(ci) + dst_cell = new_row.Cells(ci) + # Clear destination cell paragraphs then copy plain text + dst_cell.Range.Text = "" + plain = _cell_plain_text(src_cell) + # Setting Range.Text on a cell appends \r\a — pass plain only + if plain: + dst_cell.Range.Text = plain + # Best-effort: copy bold/italic from first paragraph of source + try: + src_font = src_cell.Range.Paragraphs(1).Range.Font + dst_p = dst_cell.Range.Paragraphs(1).Range + dst_p.Font.Bold = src_font.Bold + dst_p.Font.Italic = False # header data, not caption + dst_p.Font.Name = src_font.Name + dst_p.Font.Size = src_font.Size + except Exception: + pass + except Exception: + continue + new_row.HeadingFormat = True + # Ensure data rows are not marked as header + try: + for ri in range(2, int(cont_table.Rows.Count) + 1): + cont_table.Rows(ri).HeadingFormat = False + except Exception: + pass + except Exception: + # Don't leave a half-broken row — best effort only + pass diff --git a/prompts/generate-mirea-report.md b/prompts/generate-mirea-report.md index b0c0f9a..8218515 100644 --- a/prompts/generate-mirea-report.md +++ b/prompts/generate-mirea-report.md @@ -54,6 +54,7 @@ Правила заголовков: - Спецразделы **без номера**: `# *СОДЕРЖАНИЕ`, `# *ВВЕДЕНИЕ`, `# *ЗАКЛЮЧЕНИЕ`, `# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ`, `# *ПРИЛОЖЕНИЯ` — ПРОПИСНЫМИ. +- По центру в DOCX будут только «СОДЕРЖАНИЕ» и «СПИСОК…»; Введение / Заключение / ПРИЛОЖЕНИЯ — слева с отступом 1,25 см. - Нумерованные разделы основной части: `# 1 …`, `## 1.1 …` (или без цифр в тексте — нумерацию даст конвертер/Word; предпочтительно осмысленные названия без точки в конце). - В конце названия заголовка точка не ставится. @@ -99,9 +100,9 @@ | ^ | Требование 2 | ``` -### Диаграмма (UML / BPMN / C4) +### Диаграмма (PlantUML / Mermaid / схемы) -Перед fenced-блоком с языком `uml`, `plantuml`, `bpmn` или `c4` — `%id Подпись`. Конвертер рисует PNG как **Рисунок**. Опционально `+listing` — ещё и **Листинг** с исходником. +Перед fenced-блоком с языком `uml`, `plantuml`, `c4`, `uml-c4`, `usecase`, `bpmn` / `uml-bpmn`, `mermaid` / `mmd` и т.п. — `%id Подпись`. Конвертер рисует **Рисунок** (PNG; опционально SVG через `--diagram-format svg`). Опционально `+listing` — ещё и **Листинг** с исходником; `+landscape` — альбомная страница под широкий рисунок/таблицу. Схемы (`c4`, `bpmn`, …) берутся из `md2gost.schemes.json` (создаётся при первом запуске). Mermaid идёт через Kroki (не PlantUML). **IDEF0** не рисуется — вставляйте готовый PNG. ~~~markdown См. @Рисунок:usecase1 и @Листинг:usecase1. @@ -114,6 +115,28 @@ actor Student Student --> (Login) @enduml ``` + +%arch C4 контейнеры + +```uml-c4 +Person(user, "Студент") +System(app, "Портал") +Rel(user, app, "логин") +``` + +%bpmn1 Процесс заявки + +```bpmn +StartMessage(s, "заявка") +UserTask(t, "Проверить") +XOR(gw, "ок?") +End(e_ok) +End(e_no) +Flow(s, t) +Flow(t, gw) +CondFlow(gw, e_ok, "да") +DefaultFlow(gw, e_no) +``` ~~~ ### Листинг @@ -170,15 +193,27 @@ $$ ```markdown # *ПРИЛОЖЕНИЯ -## Приложение А Название +Приложение А — Листинг модуля +Приложение Б — Графический материал + +## Приложение А Листинг модуля + +… + +## Приложение Б Графический материал + +… ``` -Буквы: А, Б, В, Г, Д, Е, Ж, И, К… **Нельзя:** Ё, З, Й, О, Ч, Ь, Ы, Ъ. +- Раздел `# *ПРИЛОЖЕНИЯ` — ПРОПИСНЫМИ, **слева** (как H1), не по центру. +- При нескольких приложениях сразу после заголовка — **перечень** основным текстом. +- Каждое `## Приложение А Название` конвертер оформит как «Приложение А» (H3, по центру) + название следующей строкой. +- Буквы: А, Б, В, Г, Д, Е, Ж, И, К… **Нельзя:** Ё, З, Й, О, Ч, Ь, Ы, Ъ. ## Оформление текста (смысловые правила markdown) - Кавычки русские: «…». -- Тире в предложениях: «—» (с пробелами). Дефис в сложных словах и диапазонах: «-» без пробелов (100-150). +- Тире в предложениях: «—» (с пробелами). Дефис в сложных словах и диапазонах: «-» без пробелов (100-150). **Не заменяйте «—» на «-»** (в md2gost по умолчанию тире сохраняется). - Пробел после знака препинания, не перед ним; без пробела после «» и перед «». - Маркированный список: единообразный маркер; пункты со **строчной** буквы, в конце `;`, у последнего `.`. - Нумерованный список: с **прописной**, в конце `.`. diff --git a/prompts/generate-pis-custom-report.md b/prompts/generate-pis-custom-report.md index 728e137..f2c5db8 100644 --- a/prompts/generate-pis-custom-report.md +++ b/prompts/generate-pis-custom-report.md @@ -49,7 +49,7 @@ Листинг: `%id Название` + fenced code. -Диаграмма (Рисунок из PlantUML/BPMN/C4): +Диаграмма (Рисунок из PlantUML / Mermaid / схем): ``` %usecase1 Диаграмма прецедентов +listing @@ -62,7 +62,7 @@ User --> (Login) ``` ``` -Языки fence: `uml`, `plantuml`, `bpmn`, `c4`. Флаг `+listing` в строке `%` добавляет ещё Листинг с исходником. +Языки fence: `uml`, `plantuml`, `mermaid` / `mmd`, `c4` / `uml-c4`, `usecase`, `bpmn` / `uml-bpmn` и другие id из `md2gost.schemes.json`. Mermaid — через Kroki. IDEF0 не поддерживается. Флаг `+listing` в строке `%` добавляет ещё Листинг с исходником; `+landscape` — альбомная страница. Ссылки: `@Рисунок:usecase1`, `@Листинг:usecase1`. Формула (обычно работа №8): `$$ … $$` с `%eq_id` и ссылкой `@Формула:eq_id`. diff --git a/pyproject.toml b/pyproject.toml index 22fd3c7..aa674ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.scripts] md2gost = "md2gost.__main__:main" +md2gost-gui = "md2gost.gui:main" md2latex = "md2latex.__main__:main" md2fodt = "md2fodt.__main__:main" @@ -26,6 +27,11 @@ dev = [ "pytest>=7.0", ] +[project.optional-dependencies] +word = [ + "pywin32>=306; sys_platform == 'win32'", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -38,6 +44,8 @@ packages = ["md2gost", "md2latex", "md2fodt"] "md2gost/mml2omml" = "md2gost/mml2omml" "md2gost/diagrams" = "md2gost/diagrams" "latex/mirea" = "latex/mirea" +"prompts/generate-mirea-report.md" = "md2gost/prompts/generate-mirea-report.md" +"prompts/generate-pis-custom-report.md" = "md2gost/prompts/generate-pis-custom-report.md" # Legacy Poetry metadata (optional; prefer uv + [project] above) [tool.poetry] @@ -61,3 +69,4 @@ pygments = "^2.16.1" [tool.poetry.scripts] md2gost = "md2gost.__main__:main" +md2gost-gui = "md2gost.gui:main" diff --git a/scripts/_gen_bpmn_sprites.py b/scripts/_gen_bpmn_sprites.py new file mode 100644 index 0000000..b535935 --- /dev/null +++ b/scripts/_gen_bpmn_sprites.py @@ -0,0 +1,461 @@ +"""One-shot generator for BPMN.puml geometric sprites. Run from repo root.""" +from __future__ import annotations + +import math +from pathlib import Path + +N = 48 +HEX = "0123456789ABCDEF" +SS = 2 + + +def clamp01(v: float) -> float: + return 0.0 if v < 0 else 1.0 if v > 1 else v + + +def mix(grid: list[list[float]], x: int, y: int, v: float) -> None: + if 0 <= x < N and 0 <= y < N: + grid[y][x] = max(grid[y][x], clamp01(v)) + + +def ring(d: float, r: float, hw: float) -> float: + return clamp01(1.0 - abs(d - r) / hw) + + +def disk(d: float, r: float, hw: float = 0.65) -> float: + if d <= r - hw: + return 1.0 + if d >= r + hw: + return 0.0 + return clamp01((r + hw - d) / (2 * hw)) + + +def line_cov(px: float, py: float, x0: float, y0: float, x1: float, y1: float, hw: float) -> float: + dx, dy = x1 - x0, y1 - y0 + len2 = dx * dx + dy * dy + if len2 < 1e-6: + return disk(math.hypot(px - x0, py - y0), 0, hw) + t = clamp01(((px - x0) * dx + (py - y0) * dy) / len2) + qx, qy = x0 + t * dx, y0 + t * dy + return disk(math.hypot(px - qx, py - qy), 0, hw) + + +def diamond_m(px: float, py: float, cx: float, cy: float) -> float: + return abs(px - cx) + abs(py - cy) + + +def sample_event( + px: float, + py: float, + *, + kind: str, + dashed: bool, +) -> float: + cx = cy = (N - 1) / 2 + d = math.hypot(px - cx, py - cy) + v = 0.0 + if kind == "start": + v = max(v, ring(d, 18.2, 1.15)) + elif kind == "inter": + v = max(v, ring(d, 18.4, 1.05)) + v = max(v, ring(d, 14.6, 1.05)) + elif kind == "end": + v = max(v, ring(d, 17.6, 2.55)) + elif kind == "endfill": + v = max(v, ring(d, 17.6, 2.55)) + v = max(v, disk(d, 7.2, 0.7)) + if dashed and v > 0: + ang = (math.atan2(py - cy, px - cx) + math.pi) / (2 * math.pi) + if int(ang * 18) % 2 == 1: + v *= 0.08 + return v + + +def icon_msg(px: float, py: float, cx: float, cy: float, filled: bool) -> float: + # envelope 16x10 around center + x0, y0, x1, y1 = cx - 8, cy - 5, cx + 8, cy + 5 + v = 0.0 + # outer rect + inside = (x0 + 0.8 <= px <= x1 - 0.8) and (y0 + 0.8 <= py <= y1 - 0.8) + on_border = ( + (abs(px - x0) < 1.1 or abs(px - x1) < 1.1) and (y0 - 0.4 <= py <= y1 + 0.4) + ) or ( + (abs(py - y0) < 1.1 or abs(py - y1) < 1.1) and (x0 - 0.4 <= px <= x1 + 0.4) + ) + if filled: + if inside or on_border: + v = 1.0 + # inverted flap (white cut) approximated by not drawing center of flap + v = max(v, line_cov(px, py, x0, y0, cx, cy + 1.5, 0.9)) + v = max(v, line_cov(px, py, x1, y0, cx, cy + 1.5, 0.9)) + return v + v = max(v, 1.0 if on_border else 0.0) + v = max(v, line_cov(px, py, x0, y0, cx, cy + 1.8, 0.95)) + v = max(v, line_cov(px, py, x1, y0, cx, cy + 1.8, 0.95)) + return v + + +def icon_timer(px: float, py: float, cx: float, cy: float) -> float: + d = math.hypot(px - cx, py - cy) + v = ring(d, 7.2, 1.0) + v = max(v, line_cov(px, py, cx, cy, cx, cy - 5.2, 0.9)) + v = max(v, line_cov(px, py, cx, cy, cx + 3.6, cy, 0.9)) + return v + + +def icon_error(px: float, py: float, cx: float, cy: float) -> float: + pts = [ + (cx + 1, cy - 8), + (cx - 2, cy - 1), + (cx + 3, cy - 1), + (cx - 1, cy + 8), + (cx + 0.5, cy + 1.5), + (cx - 3.5, cy + 1.5), + ] + v = 0.0 + for a, b in zip(pts, pts[1:] + pts[:1]): + v = max(v, line_cov(px, py, a[0], a[1], b[0], b[1], 1.0)) + return v + + +def icon_signal(px: float, py: float, cx: float, cy: float, filled: bool) -> float: + a, b, c = (cx, cy - 7.5), (cx - 7.2, cy + 6), (cx + 7.2, cy + 6) + v = 0.0 + v = max(v, line_cov(px, py, *a, *b, 1.05)) + v = max(v, line_cov(px, py, *b, *c, 1.05)) + v = max(v, line_cov(px, py, *c, *a, 1.05)) + if filled: + # barycentric fill + def area(p, q, r): + return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + + p = (px, py) + a0 = area(a, b, c) + if abs(a0) > 0.1: + w1 = area(p, b, c) / a0 + w2 = area(a, p, c) / a0 + w3 = area(a, b, p) / a0 + if min(w1, w2, w3) >= -0.02: + v = max(v, 1.0) + return v + + +def icon_esc(px: float, py: float, cx: float, cy: float) -> float: + v = 0.0 + v = max(v, line_cov(px, py, cx, cy + 7, cx, cy - 2, 1.1)) + v = max(v, line_cov(px, py, cx, cy - 2, cx - 6, cy + 4, 1.05)) + v = max(v, line_cov(px, py, cx, cy - 2, cx + 6, cy + 4, 1.05)) + v = max(v, line_cov(px, py, cx - 6, cy + 4, cx + 6, cy + 4, 1.0)) + return v + + +def icon_link(px: float, py: float, cx: float, cy: float) -> float: + v = line_cov(px, py, cx - 8, cy, cx + 5, cy, 1.1) + v = max(v, line_cov(px, py, cx + 5, cy, cx + 1, cy - 4, 1.05)) + v = max(v, line_cov(px, py, cx + 5, cy, cx + 1, cy + 4, 1.05)) + return v + + +def icon_comp(px: float, py: float, cx: float, cy: float) -> float: + v = 0.0 + for ox in (-4.2, 3.2): + a, b, c = (cx + ox + 4, cy), (cx + ox - 3.5, cy - 6), (cx + ox - 3.5, cy + 6) + v = max(v, line_cov(px, py, *a, *b, 1.0)) + v = max(v, line_cov(px, py, *a, *c, 1.0)) + v = max(v, line_cov(px, py, *b, *c, 1.0)) + return v + + +def icon_cond(px: float, py: float, cx: float, cy: float) -> float: + x0, y0, x1, y1 = cx - 6, cy - 8, cx + 6, cy + 8 + v = 0.0 + on = ( + (abs(px - x0) < 1.0 or abs(px - x1) < 1.0) and (y0 <= py <= y1) + ) or ( + (abs(py - y0) < 1.0 or abs(py - y1) < 1.0) and (x0 <= px <= x1) + ) + if on: + v = 1.0 + for yy in (cy - 3, cy, cy + 3): + v = max(v, line_cov(px, py, cx - 3.5, yy, cx + 3.5, yy, 0.8)) + return v + + +def icon_multi(px: float, py: float, cx: float, cy: float) -> float: + r = 7.4 + pts = [] + for i in range(5): + a = -math.pi / 2 + i * 2 * math.pi / 5 + pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) + v = 0.0 + for a, b in zip(pts, pts[1:] + pts[:1]): + v = max(v, line_cov(px, py, *a, *b, 1.0)) + return v + + +def icon_par_multi(px: float, py: float, cx: float, cy: float) -> float: + v = ring(math.hypot(px - cx, py - cy), 7.4, 1.0) + v = max(v, line_cov(px, py, cx, cy - 5, cx, cy + 5, 1.05)) + v = max(v, line_cov(px, py, cx - 5, cy, cx + 5, cy, 1.05)) + return v + + +def icon_cancel(px: float, py: float, cx: float, cy: float) -> float: + v = line_cov(px, py, cx - 6, cy - 6, cx + 6, cy + 6, 1.15) + v = max(v, line_cov(px, py, cx + 6, cy - 6, cx - 6, cy + 6, 1.15)) + return v + + +def icon_user(px: float, py: float, cx: float, cy: float) -> float: + v = disk(math.hypot(px - cx, py - (cy - 3.2)), 3.1, 0.7) + v = max(v, ring(math.hypot(px - cx, py - (cy - 3.2)), 3.1, 0.9)) + # shoulders + v = max(v, line_cov(px, py, cx - 6, cy + 7, cx + 6, cy + 7, 1.1)) + v = max(v, line_cov(px, py, cx - 6, cy + 7, cx - 4, cy + 1.5, 1.0)) + v = max(v, line_cov(px, py, cx + 6, cy + 7, cx + 4, cy + 1.5, 1.0)) + return v + + +def icon_service(px: float, py: float, cx: float, cy: float) -> float: + v = ring(math.hypot(px - cx, py - cy), 4.2, 1.15) + for i in range(8): + a = i * math.pi / 4 + v = max( + v, + line_cov( + px, py, + cx + 3.2 * math.cos(a), cy + 3.2 * math.sin(a), + cx + 7.4 * math.cos(a), cy + 7.4 * math.sin(a), + 1.05, + ), + ) + return v + + +def icon_script(px: float, py: float, cx: float, cy: float) -> float: + return icon_cond(px, py, cx, cy) + + +def icon_manual(px: float, py: float, cx: float, cy: float) -> float: + v = line_cov(px, py, cx - 7, cy + 2, cx + 7, cy + 2, 1.2) + v = max(v, line_cov(px, py, cx - 7, cy + 2, cx - 5, cy - 4, 1.0)) + v = max(v, line_cov(px, py, cx - 2, cy + 2, cx - 1, cy - 6, 1.0)) + v = max(v, line_cov(px, py, cx + 2, cy + 2, cx + 3, cy - 5.5, 1.0)) + v = max(v, line_cov(px, py, cx + 6, cy + 2, cx + 7, cy - 3, 1.0)) + return v + + +def icon_rule(px: float, py: float, cx: float, cy: float) -> float: + v = 0.0 + for yy in (cy - 6, cy, cy + 6): + v = max(v, line_cov(px, py, cx - 7, yy, cx + 7, yy, 0.95)) + for xx in (cx - 7, cx, cx + 7): + v = max(v, line_cov(px, py, xx, cy - 6, xx, cy + 6, 0.95)) + return v + + +def icon_plusbox(px: float, py: float, cx: float, cy: float) -> float: + x0, y0, x1, y1 = cx - 7, cy - 7, cx + 7, cy + 7 + v = 0.0 + on = ( + (abs(px - x0) < 1.05 or abs(px - x1) < 1.05) and (y0 <= py <= y1) + ) or ( + (abs(py - y0) < 1.05 or abs(py - y1) < 1.05) and (x0 <= px <= x1) + ) + if on: + v = 1.0 + v = max(v, line_cov(px, py, cx, cy - 4.5, cx, cy + 4.5, 1.05)) + v = max(v, line_cov(px, py, cx - 4.5, cy, cx + 4.5, cy, 1.05)) + return v + + +ICONS = { + "": None, + "msg": lambda px, py, cx, cy: icon_msg(px, py, cx, cy, False), + "msgf": lambda px, py, cx, cy: icon_msg(px, py, cx, cy, True), + "timer": icon_timer, + "error": icon_error, + "signal": lambda px, py, cx, cy: icon_signal(px, py, cx, cy, False), + "signalf": lambda px, py, cx, cy: icon_signal(px, py, cx, cy, True), + "esc": icon_esc, + "link": icon_link, + "comp": icon_comp, + "cond": icon_cond, + "multi": icon_multi, + "par": icon_par_multi, + "cancel": icon_cancel, + "user": icon_user, + "service": icon_service, + "script": icon_script, + "manual": icon_manual, + "rule": icon_rule, + "plusbox": icon_plusbox, +} + + +def raster(draw) -> list[list[float]]: + acc = [[0.0] * N for _ in range(N)] + big = N * SS + for y in range(big): + for x in range(big): + v = draw((x + 0.5) / SS, (y + 0.5) / SS) + acc[y // SS][x // SS] += v + s = float(SS * SS) + return [[acc[y][x] / s for x in range(N)] for y in range(N)] + + +def to_sprite(name: str, grid: list[list[float]]) -> str: + lines = [f"sprite ${name} [{N}x{N}/16] {{"] + for row in grid: + lines.append("".join(HEX[min(15, int(round(v * 15)))] for v in row)) + lines.append("}") + return "\n".join(lines) + + +def event_grid(kind: str, icon: str, dashed: bool = False) -> list[list[float]]: + cx = cy = (N - 1) / 2.0 + fn = ICONS[icon] + + def draw(px, py): + v = sample_event(px, py, kind=kind, dashed=dashed) + if fn is not None: + v = max(v, fn(px, py, cx, cy)) + return v + + return raster(draw) + + +def gateway_grid(mark: str) -> list[list[float]]: + cx = cy = (N - 1) / 2.0 + r = 21.0 + + def draw(px, py): + m = diamond_m(px, py, cx, cy) + v = ring(m, r, 1.25) + if mark == "x": + v = max(v, line_cov(px, py, cx - 8, cy - 8, cx + 8, cy + 8, 1.25)) + v = max(v, line_cov(px, py, cx + 8, cy - 8, cx - 8, cy + 8, 1.25)) + elif mark == "+": + v = max(v, line_cov(px, py, cx, cy - 9, cx, cy + 9, 1.3)) + v = max(v, line_cov(px, py, cx - 9, cy, cx + 9, cy, 1.3)) + elif mark == "o": + v = max(v, ring(math.hypot(px - cx, py - cy), 7.0, 1.15)) + elif mark == "*": + for i in range(4): + a = i * math.pi / 4 + v = max( + v, + line_cov( + px, py, + cx - 7 * math.cos(a), cy - 7 * math.sin(a), + cx + 7 * math.cos(a), cy + 7 * math.sin(a), + 1.05, + ), + ) + elif mark == "ev": + v = max(v, icon_multi(px, py, cx, cy)) + v = max(v, ring(math.hypot(px - cx, py - cy), 10.5, 0.95)) + elif mark == "pev": + v = max(v, icon_par_multi(px, py, cx, cy)) + return v + + return raster(draw) + + +EVENTS = [ + ("evStart", "start", "", False), + ("evStartMsg", "start", "msg", False), + ("evStartTimer", "start", "timer", False), + ("evStartSignal", "start", "signal", False), + ("evStartCond", "start", "cond", False), + ("evStartError", "start", "error", False), + ("evStartEsc", "start", "esc", False), + ("evStartMulti", "start", "multi", False), + ("evStartPar", "start", "par", False), + ("evStartNI", "start", "", True), + ("evStartMsgNI", "start", "msg", True), + ("evStartTimerNI", "start", "timer", True), + ("evStartSignalNI", "start", "signal", True), + ("evInter", "inter", "", False), + ("evCatchMsg", "inter", "msg", False), + ("evCatchTimer", "inter", "timer", False), + ("evCatchSignal", "inter", "signal", False), + ("evCatchError", "inter", "error", False), + ("evCatchEsc", "inter", "esc", False), + ("evCatchCond", "inter", "cond", False), + ("evCatchLink", "inter", "link", False), + ("evCatchComp", "inter", "comp", False), + ("evCatchCancel", "inter", "cancel", False), + ("evCatchMulti", "inter", "multi", False), + ("evCatchPar", "inter", "par", False), + ("evThrowMsg", "inter", "msgf", False), + ("evThrowSignal", "inter", "signalf", False), + ("evThrowEsc", "inter", "esc", False), + ("evThrowLink", "inter", "link", False), + ("evThrowComp", "inter", "comp", False), + ("evThrowMulti", "inter", "multi", False), + ("evEnd", "end", "", False), + ("evEndMsg", "end", "msgf", False), + ("evEndError", "end", "error", False), + ("evEndEsc", "end", "esc", False), + ("evEndCancel", "end", "cancel", False), + ("evEndComp", "end", "comp", False), + ("evEndSignal", "end", "signalf", False), + ("evEndMulti", "end", "multi", False), + ("evTerminate", "endfill", "", False), + ("evBoundMsg", "inter", "msg", False), + ("evBoundTimer", "inter", "timer", False), + ("evBoundError", "inter", "error", False), + ("evBoundEsc", "inter", "esc", False), + ("evBoundSignal", "inter", "signal", False), + ("evBoundCancel", "inter", "cancel", False), + ("evBoundComp", "inter", "comp", False), + ("evBoundCond", "inter", "cond", False), + ("evBoundMsgNI", "inter", "msg", True), + ("evBoundTimerNI", "inter", "timer", True), + ("evBoundEscNI", "inter", "esc", True), + ("evBoundSignalNI", "inter", "signal", True), + ("evBoundCondNI", "inter", "cond", True), +] + +GATES = [ + ("gwXor", "x"), + ("gwAnd", "+"), + ("gwOr", "o"), + ("gwComplex", "*"), + ("gwEvent", "ev"), + ("gwParEvent", "pev"), +] + +TASK_ICONS = ["user", "service", "script", "manual", "rule", "plusbox", "msg", "msgf"] + + +def main() -> None: + chunks = ["' auto-generated geometric sprites (BPMN 2.0 notation)"] + for name, kind, icon, dashed in EVENTS: + chunks.append(to_sprite(name, event_grid(kind, icon, dashed))) + chunks.append("") + for name, mark in GATES: + chunks.append(to_sprite(name, gateway_grid(mark))) + chunks.append("") + cx = cy = (N - 1) / 2.0 + for icon in TASK_ICONS: + fn = ICONS[icon] + grid = raster(lambda px, py, fn=fn: fn(px, py, cx, cy)) + chunks.append(to_sprite("ic" + icon.capitalize() if icon != "msgf" else "icMsgf", grid)) + chunks.append("") + # plusbox already as icPlusbox; loop/adhoc/mi as simple extra + def loop_draw(px, py): + v = ring(math.hypot(px - cx, py - (cy - 1)), 8.5, 1.1) + v = max(v, line_cov(px, py, cx + 6, cy + 2, cx + 10, cy + 6, 1.05)) + v = max(v, line_cov(px, py, cx + 6, cy + 2, cx + 2, cy + 6, 1.05)) + return v + + chunks.append(to_sprite("icLoop", raster(loop_draw))) + chunks.append("") + Path("_bpmn_sprites.puml").write_text("\n".join(chunks), encoding="utf-8") + print("wrote", len(chunks), "chunks") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_page_fill.bas b/scripts/check_page_fill.bas new file mode 100644 index 0000000..42bc75f --- /dev/null +++ b/scripts/check_page_fill.bas @@ -0,0 +1,85 @@ +Attribute VB_Name = "CheckPageFill" +' Heuristic check for half-empty pages inside an unfinished section (MIREA method guide, app. V). +' Run from Word: Tools → Macro → Macros → CheckPageFill_Run +' Findings may be FALSE POSITIVES (large figures, end of subsection, odd breaks). + +Option Explicit + +Private Const EMPTY_THRESHOLD As Double = 0.4 + +Public Sub CheckPageFill_Run() + Dim doc As Document + Dim i As Long, n As Long + Dim msg As String + Dim issues As Long + Dim secTitle As String, nextTitle As String + Dim emptyFrac As Double + Dim contentBottom As Double + Dim textH As Double, topM As Double, pageH As Double + Dim rng As Range + Dim vpos As Double + + Set doc = ActiveDocument + doc.Repaginate + n = doc.ComputeStatistics(wdStatisticPages) + issues = 0 + msg = "Проверка полупустых страниц (эвристика; возможны ложные срабатывания)" & vbCrLf + + For i = 1 To n + Selection.GoTo What:=wdGoToPage, Which:=wdGoToAbsolute, Count:=i + If Selection.Sections(1).PageSetup.Orientation = wdOrientLandscape Then GoTo NextPage + + pageH = Selection.Sections(1).PageSetup.PageHeight + topM = Selection.Sections(1).PageSetup.TopMargin + textH = pageH - topM - Selection.Sections(1).PageSetup.BottomMargin + If textH <= 0 Then GoTo NextPage + + Set rng = doc.Bookmarks("\page").Range + On Error Resume Next + vpos = rng.Information(wdVerticalPositionRelativeToPage) + On Error GoTo 0 + contentBottom = (vpos - topM) / textH + If contentBottom < 0 Then contentBottom = 0 + If contentBottom > 1 Then contentBottom = 1 + emptyFrac = 1# - contentBottom + + If emptyFrac <= EMPTY_THRESHOLD Then GoTo NextPage + If i = n Then GoTo NextPage + + secTitle = HeadingNear(doc, rng.Start) + Selection.GoTo What:=wdGoToPage, Which:=wdGoToAbsolute, Count:=i + 1 + nextTitle = HeadingNear(doc, Selection.Start) + If StrComp(secTitle, nextTitle, vbTextCompare) <> 0 Then GoTo NextPage + If InStr(1, UCase$(secTitle), "СОДЕРЖАНИЕ", vbTextCompare) > 0 Then GoTo NextPage + + issues = issues + 1 + msg = msg & "стр. " & i & ": пустой низ ~" & Format(emptyFrac, "0%") & _ + ", раздел «" & secTitle & "» продолжается" & vbCrLf +NextPage: + Next i + + If issues = 0 Then + MsgBox msg & vbCrLf & "Замечаний нет.", vbInformation + Else + MsgBox msg & vbCrLf & "Всего: " & issues & " (эвристика).", vbExclamation + End If +End Sub + +Private Function HeadingNear(doc As Document, pos As Long) As String + Dim p As Paragraph + Dim s As String + On Error Resume Next + Set p = doc.Range(pos, pos).Paragraphs(1) + Dim k As Long + For k = 1 To 80 + s = p.Style + If InStr(1, s, "Heading 1", vbTextCompare) > 0 Or InStr(1, s, "Заголовок 1", vbTextCompare) > 0 Then + HeadingNear = Replace(Trim$(p.Range.Text), vbCr, "") + Exit Function + End If + If p.Range.Start <= 1 Then Exit For + Set p = p.Previous + If p Is Nothing Then Exit For + Next k + HeadingNear = "" +End Function diff --git a/scripts/fetch_plantuml.py b/scripts/fetch_plantuml.py new file mode 100644 index 0000000..dbf22a3 --- /dev/null +++ b/scripts/fetch_plantuml.py @@ -0,0 +1,30 @@ +"""Download official plantuml.jar into md2gost/vendor (for exe builds).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from md2gost.diagram_renderer import PLANTUML_JAR_URL, fetch_plantuml_jar, vendor_plantuml_path + + +def main() -> int: + dest = vendor_plantuml_path() + print(f"PlantUML: {PLANTUML_JAR_URL}") + print(f"Куда: {dest}") + if dest.is_file() and dest.stat().st_size > 1000: + print("Уже скачан.") + return 0 + if fetch_plantuml_jar(dest): + print(f"Готово: {dest} ({dest.stat().st_size} байт)") + return 0 + print("Не удалось скачать plantuml.jar (exe соберётся, диаграммы пойдут через kroki.io).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/md2gost_exe.py b/scripts/md2gost_exe.py new file mode 100644 index 0000000..27558a4 --- /dev/null +++ b/scripts/md2gost_exe.py @@ -0,0 +1,6 @@ +"""PyInstaller entry point: GUI by default, CLI if a .md is passed.""" + +from md2gost.__main__ import main + +if __name__ == "__main__": + main() diff --git a/scripts/pyi_rth_mplbackend.py b/scripts/pyi_rth_mplbackend.py new file mode 100644 index 0000000..2c24357 --- /dev/null +++ b/scripts/pyi_rth_mplbackend.py @@ -0,0 +1,4 @@ +"""Runtime hook: matplotlib only needs Agg for font_manager on a foreign PC.""" +import os + +os.environ.setdefault("MPLBACKEND", "Agg") diff --git a/tests/test_diagram_renderer.py b/tests/test_diagram_renderer.py index f1a674c..e4ca478 100644 --- a/tests/test_diagram_renderer.py +++ b/tests/test_diagram_renderer.py @@ -1,16 +1,35 @@ -"""Tests for diagram renderer and factory routing.""" +"""Tests for diagram renderer, schemes, and include cache.""" from __future__ import annotations +from pathlib import Path from unittest.mock import patch import pytest +from md2gost.diagram_includes import ( + include_cache_index_path, + reset_include_cache, + resolve_url, + rewrite_http_includes_in_source, +) from md2gost.diagram_renderer import ( DIAGRAM_LANGS, + configure_diagrams, + diagram_engine_status, + is_diagram_lang, prepare_source, render_diagram, - configure_diagrams, + resolve_plantuml_jar, +) +from md2gost.diagram_schemes import ( + DiagramScheme, + apply_scheme, + configure_schemes, + ensure_user_schemes, + prepare_with_schemes, + scheme_id_from_lang, + user_schemes_path, ) from md2gost.extended_markdown import markdown from md2gost.extended_markdown.caption import Caption as CapEl @@ -26,16 +45,148 @@ def test_prepare_uml_wraps_startuml(): assert dtype == "plantuml" -def test_prepare_c4_adds_include(): - src, dtype = prepare_source("c4", "Person(user, \"User\")") - assert "!include" in src +def test_prepare_c4_uses_scheme(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + ensure_user_schemes(tmp_path) + configure_schemes(base=tmp_path) + + def fake_resolve(ref, *, base=None, schemes_dir=None): + return str(tmp_path / "C4_Container.puml") + + with patch("md2gost.diagram_schemes.resolve_include_ref", side_effect=fake_resolve): + src, dtype = prepare_with_schemes("c4", 'Person(user, "U")', base=tmp_path) assert dtype == "plantuml" + assert "Person(user" in src + assert "@startuml" in src.lower() + assert "!include" in src -def test_prepare_bpmn(): - src, dtype = prepare_source("bpmn", "start -> end") - assert dtype == "bpmn" - assert "@startbpmn" in src.lower() or "start" in src +def test_prepare_uml_c4_alias(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + ensure_user_schemes(tmp_path) + configure_schemes(base=tmp_path) + + def fake_resolve(ref, *, base=None, schemes_dir=None): + return str(tmp_path / "stub.puml") + + with patch("md2gost.diagram_schemes.resolve_include_ref", side_effect=fake_resolve): + src, dtype = prepare_with_schemes("uml-c4", 'Person(user, "U")', base=tmp_path) + assert dtype == "plantuml" + assert "Person(user" in src + + +def test_unknown_scheme_raises(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + ensure_user_schemes(tmp_path) + configure_schemes(base=tmp_path) + with pytest.raises(ValueError, match="не найдена"): + prepare_with_schemes("uml-nosuch", "A -> B", base=tmp_path) + + +def test_bpmn_scheme_prepare(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + ensure_user_schemes(tmp_path) + configure_schemes(base=tmp_path) + assert is_diagram_lang("bpmn") + assert is_diagram_lang("uml-bpmn") + src, dtype = prepare_with_schemes("bpmn", "Start(s)\nEnd(e)\nFlow(s, e)", base=tmp_path) + assert dtype == "plantuml" + assert "Start(s)" in src + assert "!include" in src + assert "BPMN.puml" in src.replace("\\", "/") + assert src.lower().count("@startuml") == 1 + + +def test_bundled_bpmn_survives_user_file_without_it(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + path = ensure_user_schemes(tmp_path) + path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8") + configure_schemes(base=tmp_path) + assert is_diagram_lang("mine") + assert is_diagram_lang("bpmn") + + +def test_scheme_id_from_lang(): + assert scheme_id_from_lang("uml") is None + assert scheme_id_from_lang("uml-c4") == "c4" + assert scheme_id_from_lang("c4") == "c4" + assert scheme_id_from_lang("bpmn") == "bpmn" + assert scheme_id_from_lang("uml-bpmn") == "bpmn" + + +def test_ensure_user_schemes_once(tmp_path): + path = ensure_user_schemes(tmp_path) + assert path.is_file() + assert "c4" in path.read_text(encoding="utf-8") + path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8") + again = ensure_user_schemes(tmp_path) + assert again == path + assert "mine" in path.read_text(encoding="utf-8") + assert path == user_schemes_path(tmp_path) + + +def test_apply_scheme_no_double_start(): + scheme = DiagramScheme( + id="t", + prefix="@startuml\nskinparam monochrome true\n", + postfix="\n@enduml", + includes=[], + ) + out = apply_scheme(scheme, "@startuml\nA -> B\n@enduml") + assert out.lower().count("@startuml") == 1 + assert "skinparam monochrome true" in out + + +def test_include_cache_index_no_overwrite(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + calls = [] + + def fake_get(url, timeout=60): + calls.append(url) + + class Resp: + content = f"' from {url}\n".encode("utf-8") + + def raise_for_status(self): + return None + + return Resp() + + with patch("md2gost.diagram_includes.requests.get", side_effect=fake_get): + p1 = resolve_url("https://example.com/a.puml", base=tmp_path) + p2 = resolve_url("https://example.com/b.puml", base=tmp_path) + assert p1 != p2 + assert p1.is_file() and p2.is_file() + content1 = p1.read_bytes() + # second resolve same URL — no new download + p1b = resolve_url("https://example.com/a.puml", base=tmp_path) + assert p1b == p1 + assert p1.read_bytes() == content1 + assert calls.count("https://example.com/a.puml") == 1 + assert include_cache_index_path(tmp_path).is_file() + + removed = reset_include_cache(tmp_path) + assert removed >= 2 + assert not include_cache_index_path(tmp_path).is_file() + + +def test_rewrite_http_in_source(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + def fake_get(url, timeout=60): + class Resp: + content = b"rectangle x\n" + + def raise_for_status(self): + return None + + return Resp() + + with patch("md2gost.diagram_includes.requests.get", side_effect=fake_get): + src = "@startuml\n!include https://example.com/x.puml\nA->B\n@enduml" + out = rewrite_http_includes_in_source(src, base=tmp_path) + assert "https://example.com" not in out + assert "!include " in out def test_caption_plus_listing(): @@ -47,6 +198,45 @@ def test_caption_plus_listing(): assert caps[0].unique_name == "fig" +def test_caption_plus_landscape(): + doc = markdown.parse("%wide Big scheme +landscape\n\n```uml\nA->B\n```\n") + caps = [c for c in doc.children if isinstance(c, CapEl)] + assert caps[0].landscape is True + assert caps[0].with_listing is False + assert caps[0].text == "Big scheme" + + doc2 = markdown.parse("%t1 Широкая +listing +landscape\n\n|a|b|\n|-|-|\n|1|2|\n") + caps2 = [c for c in doc2.children if isinstance(c, CapEl)] + assert caps2[0].landscape is True + assert caps2[0].with_listing is True + assert caps2[0].text == "Широкая" + + +def test_caption_interrupts_paragraph(): + """% line right after text (no blank line) must still be Caption, not plain text.""" + from marko.block import FencedCode, Paragraph + + doc = markdown.parse( + "Пишите так:\n" + "%testuml test +landscape\n" + "\n" + "```uml\nA->B\n```\n" + ) + caps = [c for c in doc.children if isinstance(c, CapEl)] + assert len(caps) == 1 + assert caps[0].landscape is True + assert caps[0].unique_name == "testuml" + joined = "" + for p in doc.children: + if not isinstance(p, Paragraph): + continue + for ch in getattr(p, "children", []) or []: + if hasattr(ch, "children") and isinstance(ch.children, str): + joined += ch.children + assert "%testuml" not in joined + assert any(isinstance(c, FencedCode) and c.lang == "uml" for c in doc.children) + + def test_factory_routes_uml_to_diagram(): from docx import Document from md2gost import package_dir @@ -77,8 +267,8 @@ def test_factory_routes_uml_to_diagram(): def test_render_diagram_uses_cache(tmp_path): png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20 - def fake_kroki(source, diagram_type, base_url, out_png): - out_png.write_bytes(png) + def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): + out_path.write_bytes(png if fmt == "png" else b"") return True configure_diagrams(fallback="remote", cache_dir=str(tmp_path)) @@ -86,8 +276,8 @@ def test_render_diagram_uses_cache(tmp_path): patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki): path1 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path)) path2 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path)) - assert path1 == path2 - assert open(path1, "rb").read().startswith(b"\x89PNG") + assert path1.png_path == path2.png_path + assert open(path1.png_path, "rb").read().startswith(b"\x89PNG") def test_render_diagram_off_raises(tmp_path): @@ -98,7 +288,202 @@ def test_render_diagram_off_raises(tmp_path): render_diagram("uml", "A -> B", fallback="off", cache_dir=str(tmp_path)) +def test_prepare_mermaid_no_startuml(): + src, dtype = prepare_source("mermaid", "flowchart LR\nA-->B") + assert "@startuml" not in src.lower() + assert dtype == "mermaid" + assert "flowchart LR" in src + src2, dtype2 = prepare_source("mmd", "sequenceDiagram\nA->>B: hi") + assert dtype2 == "mermaid" + assert "@startuml" not in src2.lower() + + +def test_plantuml_scale_injected_into_render_cache(tmp_path): + from md2gost.diagram_renderer import _ensure_plantuml_png_scale + + boosted = _ensure_plantuml_png_scale("@startuml\nA -> B\n@enduml") + assert "scale 2" in boosted + # author override kept + custom = _ensure_plantuml_png_scale("@startuml\nscale 1.5\nA -> B\n@enduml") + assert custom.count("scale") == 1 + assert "scale 1.5" in custom + + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20 + seen = [] + + def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): + seen.append(source) + out_path.write_bytes(png if fmt == "png" else b"") + return True + + configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=3) + with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \ + patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki): + result = render_diagram("uml", "A -> B", cache_dir=str(tmp_path)) + assert seen and "scale 3" in seen[0] + assert result.pixel_scale == 3 + + +def test_diagram_scale_one_skips_inject(tmp_path): + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20 + seen = [] + + def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): + seen.append(source) + out_path.write_bytes(png) + return True + + configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=1) + with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \ + patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki): + result = render_diagram("uml", "A -> B", cache_dir=str(tmp_path), diagram_scale=1) + assert seen and "scale " not in seen[0].split("@startuml", 1)[-1].split("A -> B")[0] + assert result.pixel_scale == 1 + + +def test_is_diagram_lang_mermaid(): + assert is_diagram_lang("mermaid") + assert is_diagram_lang("mmd") + assert "mermaid" in DIAGRAM_LANGS + assert "mmd" in DIAGRAM_LANGS + + +def test_render_mermaid_skips_jar(tmp_path): + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20 + calls = {"jar": 0, "kroki": []} + + def fake_jar(*args, **kwargs): + calls["jar"] += 1 + return False + + def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): + calls["kroki"].append((diagram_type, fmt, base_url)) + if fmt == "png": + out_path.write_bytes(png) + else: + out_path.write_bytes(b"") + return True + + configure_diagrams(fallback="remote", cache_dir=str(tmp_path)) + with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \ + patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki): + result = render_diagram("mermaid", "flowchart LR\nA-->B", cache_dir=str(tmp_path)) + assert calls["jar"] == 0 + assert any(t == "mermaid" and f == "png" for t, f, _ in calls["kroki"]) + assert Path(result.png_path).read_bytes().startswith(b"\x89PNG") + assert result.svg_path is None + + +def test_render_diagram_svg_asks_both_formats(tmp_path): + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20 + svg = b'' + fmts = [] + + def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): + fmts.append(fmt) + out_path.write_bytes(png if fmt == "png" else svg) + return True + + configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_format="svg") + with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \ + patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki): + result = render_diagram( + "uml", "A -> B", cache_dir=str(tmp_path), diagram_format="svg", + ) + assert "png" in fmts and "svg" in fmts + assert result.svg_path is not None + assert Path(result.svg_path).read_bytes().startswith(b"' + '', + encoding="utf-8", + ) + assert attach_svg_blip(run, picture, svg) + + xml = picture._inline.xml + assert "svgBlip" in xml + assert SVG_BLIP_URI in xml + # two image relationships on the document part + image_rels = [ + r for r in doc.part.rels.values() + if "image" in (r.reltype or "") + ] + assert len(image_rels) >= 2 + + +def test_resolve_plantuml_jar_explicit(tmp_path): + jar = tmp_path / "plantuml.jar" + jar.write_bytes(b"PK" + b"\x00" * 2000) + assert resolve_plantuml_jar(str(jar), download=False) == str(jar) + missing = tmp_path / "nope.jar" + assert resolve_plantuml_jar(str(missing), download=False) is None or ( + resolve_plantuml_jar(str(missing), download=False) != str(missing) + ) + + +def test_diagram_engine_status_mentions_java_or_kroki(): + text = diagram_engine_status() + assert "Java" in text + assert "PlantUML" in text + + def test_diagram_langs(): assert "uml" in DIAGRAM_LANGS assert "c4" in DIAGRAM_LANGS assert "bpmn" in DIAGRAM_LANGS + assert is_diagram_lang("uml") + assert is_diagram_lang("uml-c4") + assert is_diagram_lang("uml-bpmn") + + +def test_render_bpmn_with_jar(tmp_path, monkeypatch): + import shutil + + monkeypatch.chdir(tmp_path) + ensure_user_schemes(tmp_path) + configure_schemes(base=tmp_path) + jar = resolve_plantuml_jar(download=False) + if not shutil.which("java") or not jar: + pytest.skip("нужны Java и plantuml.jar") + configure_diagrams(fallback="off", cache_dir=str(tmp_path), plantuml_jar=jar) + body = "\n".join( + [ + "Start(s)", + "StartMessage(sm, \"msg\")", + "UserTask(t, \"Шаг\")", + "XOR(gw)", + "AND(p)", + "OR(o)", + "End(e)", + "EndTerminate(et)", + "Flow(s, t)", + "Flow(t, gw)", + "CondFlow(gw, e, \"да\")", + "DefaultFlow(gw, et)", + ] + ) + result = render_diagram("bpmn", body, cache_dir=str(tmp_path), fallback="off") + data = Path(result.png_path).read_bytes() + assert data.startswith(b"\x89PNG") + assert len(data) > 500 diff --git a/tests/test_landscape.py b/tests/test_landscape.py new file mode 100644 index 0000000..4354631 --- /dev/null +++ b/tests/test_landscape.py @@ -0,0 +1,200 @@ +"""Tests for landscape section around wide figures/tables.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from docx import Document +from docx.enum.section import WD_ORIENT +from PIL import Image as PILImage + +from md2gost import package_dir +from md2gost.page_geometry import content_size +from md2gost.parser_ import Parser +from md2gost.renderable.caption import CaptionInfo +from md2gost.renderable.image import Image +from md2gost.renderable.table import Table +from md2gost.renderer import Renderer + + +def test_content_size_landscape_wider(): + h_p, w_p = content_size(landscape=False) + h_l, w_l = content_size(landscape=True) + assert w_l > w_p + assert h_l < h_p + + +def test_renderer_landscape_section_for_table(): + doc = Document(os.path.join(package_dir(), "Template.docx")) + body = doc._body._element + for child in list(body): + if child.tag.endswith("}sectPr"): + continue + body.remove(child) + + renderer = Renderer(doc, skip_numbering=False) + table = Table(doc._body, 2, 4, CaptionInfo("t1", "Wide", landscape=True)) + for r in range(2): + for c in range(4): + table.add_paragraph_to_cell(r, c).add_run(f"{r},{c}") + + before = len(doc.sections) + renderer.process([table]) + after = len(doc.sections) + assert after >= before + 2 + + land = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE] + assert land, "no landscape section" + assert int(land[0].page_width) > int(land[0].page_height) + + from md2gost.styles import apply_document_styles + apply_document_styles(doc, "mirea") + land2 = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE] + assert land2 + assert int(land2[0].page_width) > int(land2[0].page_height) + + assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT + assert int(doc.sections[-1].page_width) < int(doc.sections[-1].page_height) + + +def test_parser_propagates_landscape_to_table(): + doc = Document(os.path.join(package_dir(), "Template.docx")) + md = "%t1 Широкая +landscape\n\n|A|B|C|\n|---|---|---|\n|1|2|3|\n" + renderables = list(Parser(doc, md).parse()) + assert len(renderables) == 1 + assert isinstance(renderables[0], Table) + assert renderables[0].landscape is True + + +def test_landscape_image_not_queued_for_soft_pagebreak(tmp_path, monkeypatch): + """Portrait fit-check must not queue +landscape images (empty page before figure).""" + monkeypatch.chdir(tmp_path) + png = tmp_path / "wide.png" + # Wide image that is tall when forced into portrait width + PILImage.new("RGB", (2000, 1400), color=(0, 128, 255)).save(png) + os.environ["WORKING_DIR"] = str(tmp_path) + + doc = Document(os.path.join(package_dir(), "Template.docx")) + body = doc._body._element + for child in list(body): + if child.tag.endswith("}sectPr"): + continue + body.remove(child) + + from md2gost.renderable.paragraph import Paragraph as RParagraph + from md2gost.sub_renderable import SubRenderable + + para = RParagraph(doc._body) + para.add_run("Before text that fills some space. " * 20) + img = Image( + doc._body, + str(png), + CaptionInfo("deploy", "Диаграмма", landscape=True), + ) + # Mimic factory: image attached to paragraph + para._images.append(img) + + from md2gost.layout_tracker import LayoutState + from md2gost.page_geometry import content_size + from docx.shared import Length + + max_h, max_w = content_size(landscape=False) + state = LayoutState(max_h, max_w) + # Pretend most of the page is already used so a portrait fit-check would fail + state.add_height(Length(int(max_h) - 100000)) + + infos = list(para.render(None, state)) + subs = [i for i in infos if isinstance(i, SubRenderable)] + assert len(subs) == 1 + assert subs[0].renderable is img + assert subs[0].add_to_new_page is False + + +def test_landscape_places_figure_inside_landscape_section(tmp_path, monkeypatch): + """Content must sit BEFORE body sectPr or Word ignores landscape.""" + monkeypatch.chdir(tmp_path) + png = tmp_path / "x.png" + PILImage.new("RGB", (40, 20), color=(0, 128, 255)).save(png) + + doc = Document(os.path.join(package_dir(), "Template.docx")) + body = doc._body._element + for child in list(body): + if child.tag.endswith("}sectPr"): + continue + body.remove(child) + + os.environ["WORKING_DIR"] = str(tmp_path) + img = Image( + doc._body, + str(png), + CaptionInfo("fig", "wide", landscape=True), + ) + renderer = Renderer(doc, skip_numbering=False) + renderer.process([img]) + + A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + section_idx = 0 + image_section = None + for child in doc.element.body: + tag = child.tag.split("}")[-1] + if child.findall(f".//{A}blip"): + image_section = section_idx + sect = None + if tag == "sectPr": + sect = child + elif tag == "p": + pPr = child.find(f"{W}pPr") + if pPr is not None: + sect = pPr.find(f"{W}sectPr") + if sect is not None: + section_idx += 1 + + assert any(s.orientation == WD_ORIENT.LANDSCAPE for s in doc.sections) + assert image_section is not None + assert image_section == 1 + + +def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch): + """+landscape +listing → listing must not sit in the landscape section.""" + from unittest.mock import patch + + from md2gost.renderable.caption import CaptionInfo + from md2gost.renderable.diagram import DiagramFigure + from md2gost.renderer import Renderer + from md2gost.styles import apply_mirea_styles + + monkeypatch.chdir(tmp_path) + png = tmp_path / "d.png" + PILImage.new("RGB", (40, 20), color=(0, 128, 255)).save(png) + + doc = Document(os.path.join(package_dir(), "Template.docx")) + body = doc._body._element + for child in list(body): + if child.tag.endswith("}sectPr"): + continue + body.remove(child) + apply_mirea_styles(doc) + + class FakeResult: + png_path = str(png) + svg_path = None + pixel_scale = 1.0 + + fig = DiagramFigure( + doc._body, + "uml", + "@startuml\nA->B\n@enduml", + CaptionInfo("d1", "Схема", with_listing=True, landscape=True), + with_listing=True, + ) + assert fig.listing is not None + renderer = Renderer(doc, skip_numbering=False) + with patch("md2gost.renderable.diagram.render_diagram", return_value=FakeResult()): + renderer.process([fig]) + + assert any(s.orientation == WD_ORIENT.LANDSCAPE for s in doc.sections) + texts = [p.text for p in doc.paragraphs] + listing_caps = [t for t in texts if t.startswith("Листинг")] + assert listing_caps, f"no listing caption in {texts!r}" diff --git a/tests/test_mirea_tz.py b/tests/test_mirea_tz.py index a05aad7..ca82765 100644 --- a/tests/test_mirea_tz.py +++ b/tests/test_mirea_tz.py @@ -378,3 +378,164 @@ def test_apid_biblio_min_seven(): assert any(i.id == "biblio.count" for i in issues) +def test_special_heading_alignment_soderzhanie_vs_vvedenie(): + import docx + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.shared import Cm + from md2gost.renderable.heading import Heading + from md2gost.renderer import Renderer + from md2gost.styles import apply_mirea_styles + + doc = docx.Document(r"md2gost/Template.docx") + doc._body.clear_content() + apply_mirea_styles(doc) + renderer = Renderer(doc, skip_numbering=True) + + h_toc = Heading(doc._body, 1, False) + h_toc.add_run("СОДЕРЖАНИЕ") + renderer._handle_heading(h_toc) + assert h_toc._docx_paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER + assert abs(h_toc._docx_paragraph.paragraph_format.left_indent.cm - 0) < 0.01 + + h_intro = Heading(doc._body, 1, False) + h_intro.add_run("ВВЕДЕНИЕ") + renderer._handle_heading(h_intro) + # Left like Heading 1 (style indent 1.25), not forced center + assert h_intro._docx_paragraph.alignment != WD_ALIGN_PARAGRAPH.CENTER + + +def test_appendix_item_becomes_h3_plus_title(): + import docx + from docx.enum.text import WD_ALIGN_PARAGRAPH + from md2gost.renderable.heading import Heading + from md2gost.renderer import Renderer + from md2gost.styles import apply_mirea_styles + + doc = docx.Document(r"md2gost/Template.docx") + doc._body.clear_content() + apply_mirea_styles(doc) + renderer = Renderer(doc, skip_numbering=True) + + h = Heading(doc._body, 2, True) + h.add_run("Приложение А Листинг модуля") + renderer._handle_heading(h) + assert h.style.name == "Heading 3" + assert h.text == "Приложение А" + assert h._docx_paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER + assert len(renderer._after_current) == 1 + title_p = renderer._after_current[0] + assert "Листинг модуля" in title_p._docx_paragraph.text + assert title_p._docx_paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER + + +def test_caption_table_keep_with_next_and_toc_styles(): + import docx + from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING + from md2gost.styles import apply_mirea_styles + + doc = docx.Document(r"md2gost/Template.docx") + apply_mirea_styles(doc) + assert doc.styles["Caption Table"].paragraph_format.keep_with_next is True + assert doc.styles["Table Text"].paragraph_format.alignment == WD_ALIGN_PARAGRAPH.LEFT + bh = doc.styles["Bibliography Heading"] + assert abs(bh.paragraph_format.left_indent.cm - 1.25) < 0.01 + toc1 = doc.styles["toc 1"] + assert toc1.font.all_caps is True + assert toc1.font.bold is False + assert toc1.paragraph_format.line_spacing_rule == WD_LINE_SPACING.ONE_POINT_FIVE + + +def test_emdash_default_false_in_pipeline(): + from md2gost.pipeline import ConvertRequest + assert ConvertRequest().emdash_to_hyphen is False + + +def test_object_ref_unused_warning(): + text = SAMPLE_OK.replace("@Рисунок:arch", "схема") + issues = check_markdown(text, "coursework") + assert any(i.id == "ref.unused" for i in issues) + + +def test_table_continuation_warning(): + issues = check_markdown(SAMPLE_OK, "coursework", table_continuation="off") + assert any(i.id == "table.continuation" for i in issues) + issues2 = check_markdown(SAMPLE_OK, "coursework", table_continuation="caption") + assert not any(i.id == "table.continuation" for i in issues2) + issues3 = check_markdown(SAMPLE_OK, "coursework", table_continuation="word") + assert not any(i.id == "table.continuation" for i in issues3) + + +def test_appendix_list_warning(): + text = SAMPLE_OK + "\n## Приложение Б Ещё\n\nТекст.\n" + # No list between ПРИЛОЖЕНИЯ and first appendix + issues = check_markdown(text, "coursework") + assert any(i.id == "appendix.toc" for i in issues) + + +def test_vkr_biblio_per_section(): + text = """ +# *СОДЕРЖАНИЕ +[TOC] +# *ВВЕДЕНИЕ +Текст. +# 1 Раздел +См. [1.1]. +# *ЗАКЛЮЧЕНИЕ +Выводы. +# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ +## Нормативные +[1.1]: ГОСТ 7.32-2017. — М., 2022. +## Научные +[2.1]: Иванов И. И. Книга. — М., 2023. +# *ПРИЛОЖЕНИЯ +## Приложение А Графический материал +Слайды. +""" + issues = check_markdown(text, "vkr") + assert any(i.id == "biblio.count" for i in issues) + + +def test_page_fill_evaluate_heuristic(): + from md2gost.page_fill_check import PageMetric, evaluate_page_fill + + pages = [ + PageMetric(1, 0.4, "1 Анализ", "1 Анализ"), + PageMetric(2, 0.9, "1 Анализ", "2 Проект", is_last_doc_page=False), + PageMetric(3, 0.5, "2 Проект", None, is_last_doc_page=True), + ] + issues = evaluate_page_fill(pages) + assert len(issues) == 1 + assert issues[0].page_index == 1 + assert "heuristic" in issues[0].message + + # Last page of section — no issue + pages2 = [ + PageMetric(1, 0.3, "1 Анализ", "2 Проект"), + PageMetric(2, 0.9, "2 Проект", None, is_last_doc_page=True), + ] + assert evaluate_page_fill(pages2) == [] + + # Landscape skipped + pages3 = [ + PageMetric(1, 0.2, "1 Анализ", "1 Анализ", is_landscape=True), + PageMetric(2, 0.9, "1 Анализ", None, is_last_doc_page=True), + ] + assert evaluate_page_fill(pages3) == [] + + +def test_landscape_valign_center(): + from docx import Document + from docx.oxml.ns import qn + from md2gost import package_dir + import os + from md2gost.page_geometry import apply_section_geometry + + doc = Document(os.path.join(package_dir(), "Template.docx")) + section = doc.sections[0] + apply_section_geometry(section, landscape=True) + valign = section._sectPr.find(qn("w:vAlign")) + assert valign is not None + assert valign.get(qn("w:val")) == "center" + apply_section_geometry(section, landscape=False) + assert section._sectPr.find(qn("w:vAlign")) is None + diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..57236e9 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,186 @@ +"""CLI/GUI shared pipeline helpers.""" + +import os +import sys + +from md2gost.dnd import first_markdown, normalize_drop_paths, parse_tkdnd_files +from md2gost.pipeline import ConvertRequest, convert, default_output_path, should_launch_gui +from md2gost.__main__ import build_parser, request_from_args + + +def test_should_launch_gui(): + assert should_launch_gui(None, False) is True + assert should_launch_gui("", False) is True + assert should_launch_gui("a.md", True) is True + assert should_launch_gui("a.md", False) is False + + +def test_default_output_path(): + path = default_output_path(r"C:\docs\report.md") + assert path.endswith("report.docx") + + +def test_parse_tkdnd_files(): + raw = r"{C:\My Files\a.md} C:\tmp\b.md" + assert parse_tkdnd_files(raw) == [r"C:\My Files\a.md", r"C:\tmp\b.md"] + + +def test_normalize_and_first_markdown(tmp_path): + md = tmp_path / "note.md" + png = tmp_path / "pic.png" + md.write_text("# hi\n", encoding="utf-8") + png.write_text("x", encoding="utf-8") + paths = normalize_drop_paths([str(png), str(md)]) + assert first_markdown(paths) == os.path.normpath(str(md)) + + +def test_convert_rejects_non_md(tmp_path): + txt = tmp_path / "file.txt" + txt.write_text("nope", encoding="utf-8") + result = convert(ConvertRequest(filename=str(txt))) + assert result.ok is False + assert result.exit_code == 1 + + +def test_convert_missing_file(): + result = convert(ConvertRequest(filename="definitely-missing.md")) + assert result.ok is False + assert result.exit_code == 2 + + +def test_parser_gui_and_optional_file(): + parser = build_parser() + args = parser.parse_args(["--gui", "report.md", "--type", "PIS_custom"]) + assert args.gui is True + assert args.filename == "report.md" + req = request_from_args(args) + assert req.doc_type == "PIS_custom" + assert req.filename == "report.md" + + +def test_win32_drop_api_pointer_width(): + if sys.platform != "win32": + return + import ctypes + from md2gost.dnd import _win32_drop_api + api = _win32_drop_api() + assert api.CallWindowProc.argtypes[0] is ctypes.c_void_p + huge = (1 << 40) | 0x1234 + api.CallWindowProc.argtypes[0](huge) # must not OverflowError + + +def test_win32_drop_hook_survives_window_messages(): + if sys.platform != "win32": + return + import tkinter as tk + from md2gost.dnd import enable_file_drop + root = tk.Tk() + try: + root.geometry("240x160+40+40") + root.update() + kind = enable_file_drop(root, lambda _paths: None) + root.update() + root.event_generate("", x=12, y=12) + root.update() + assert kind in {"win32", "tkdnd", "none"} + finally: + root.destroy() + + +def test_argv_needs_console(): + from md2gost.__main__ import _argv_needs_console + assert _argv_needs_console(["md2gost"]) is False + assert _argv_needs_console(["md2gost", "--gui"]) is False + assert _argv_needs_console(["md2gost", "--help"]) is True + assert _argv_needs_console(["md2gost", "report.md"]) is True + assert _argv_needs_console(["md2gost", "--gui", "report.md"]) is False + + +def test_gui_module_imports(): + from md2gost import gui + assert hasattr(gui, "run_gui") + assert hasattr(gui, "main") + + +def test_hr_pagebreak_becomes_page_break(): + import os + import docx + from md2gost import package_dir + from md2gost.parser_ import Parser + from md2gost.renderable.page_break import PageBreak + + doc = docx.Document(os.path.join(package_dir(), "Template.docx")) + doc._body.clear_content() + text = "До разрыва.\n\n---\n\nПосле разрыва.\n" + items = list(Parser(doc, text, hr_pagebreak=True).parse()) + assert any(isinstance(x, PageBreak) for x in items) + + doc2 = docx.Document(os.path.join(package_dir(), "Template.docx")) + doc2._body.clear_content() + items_off = list(Parser(doc2, text, hr_pagebreak=False).parse()) + assert not any(isinstance(x, PageBreak) for x in items_off) + texts = [] + for item in items_off: + para = getattr(item, "_docx_paragraph", None) + if para is not None: + texts.append(para.text) + assert not any("ThematicBreak" in t for t in texts) + + +def test_prompt_catalog_loads(): + from md2gost.help_content import load_prompt_catalog + catalog = load_prompt_catalog() + names = {name for name, _title, text in catalog} + assert "generate-mirea-report.md" in names + assert "generate-pis-custom-report.md" in names + assert all(text.strip() for _n, _t, text in catalog) + + +def test_listing_continuation_mode(): + import os + import docx + from md2gost import package_dir + from md2gost.profiles import DEFAULT_LISTING_CONTINUATION + from md2gost.renderable.caption import CaptionInfo + from md2gost.renderable.listing import Listing + + doc = docx.Document(os.path.join(package_dir(), "Template.docx")) + doc._body.clear_content() + listing = Listing(doc._body, "python", CaptionInfo("c1", "Код")) + listing.set_text("print(1)\n") + assert listing._continuation_mode == DEFAULT_LISTING_CONTINUATION + listing.set_continuation_mode("caption") + assert listing._continuation_mode == "caption" + try: + listing.set_continuation_mode("nope") + raise AssertionError("expected ValueError") + except ValueError: + pass + + +def test_parser_listing_continuation_flag(): + parser = build_parser() + args = parser.parse_args(["report.md", "--listing-continuation", "legacy"]) + req = request_from_args(args) + assert req.listing_continuation == "legacy" + args2 = parser.parse_args(["report.md"]) + assert request_from_args(args2).listing_continuation == "word" + + +def test_parser_hr_pagebreak_flag(): + parser = build_parser() + args = parser.parse_args(["report.md", "--no-hr-pagebreak"]) + req = request_from_args(args) + assert req.hr_pagebreak is False + args2 = parser.parse_args(["report.md"]) + assert request_from_args(args2).hr_pagebreak is False + assert request_from_args(args2).doc_type == "practice" + args3 = parser.parse_args(["report.md", "--hr-pagebreak"]) + assert request_from_args(args3).hr_pagebreak is True + + +def test_parser_no_file_is_ok(): + parser = build_parser() + args = parser.parse_args([]) + assert args.filename is None + assert args.gui is False diff --git a/tests/test_table_merge.py b/tests/test_table_merge.py index f142f7f..63b70a0 100644 --- a/tests/test_table_merge.py +++ b/tests/test_table_merge.py @@ -157,14 +157,14 @@ def test_checker_merge_ok(): def test_table_continuation_modes(): from md2gost.profiles import TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION - assert DEFAULT_TABLE_CONTINUATION == "off" - assert TABLE_CONTINUATION_MODES == ("off", "legacy", "caption", "soft") + assert DEFAULT_TABLE_CONTINUATION == "word" + assert TABLE_CONTINUATION_MODES == ("off", "legacy", "caption", "soft", "word") doc = Document(os.path.join(package_dir(), "Template.docx")) from md2gost.renderable.table import Table as RTable from md2gost.renderable.caption import CaptionInfo t = RTable(doc._body, 1, 2, CaptionInfo("x", "y")) - assert t._continuation_mode == "off" + assert t._continuation_mode == "word" t.set_continuation_mode("legacy") assert t._continuation_mode == "legacy" t.set_continuation_mode("caption") diff --git a/tests/test_word_fix.py b/tests/test_word_fix.py new file mode 100644 index 0000000..adfce33 --- /dev/null +++ b/tests/test_word_fix.py @@ -0,0 +1,110 @@ +"""Tests for Word COM table/listing continuation post-process (pure + optional COM).""" + +from __future__ import annotations + +import os +import sys +import tempfile + +import pytest + +from md2gost.profiles import LISTING_CONTINUATION_MODES, TABLE_CONTINUATION_MODES +from md2gost.word_fix import ( + continuation_label, + find_page_break_row, + fix_continuations, + parse_caption_text, +) + + +def test_word_mode_in_profiles(): + assert "word" in TABLE_CONTINUATION_MODES + assert "word" in LISTING_CONTINUATION_MODES + + +@pytest.mark.parametrize( + "text, kind, number, cont", + [ + ("Таблица 2.1 — Название", "table", "2.1", False), + ("Таблица 1", "table", "1", False), + ("Продолжение Таблицы 2.1", "table", "2.1", True), + ("Листинг 3 — Код", "listing", "3", False), + ("Продолжение Листинга 3", "listing", "3", True), + ("Таблицы 1.2", "table", "1.2", False), + ], +) +def test_parse_caption_text(text, kind, number, cont): + info = parse_caption_text(text) + assert info is not None + assert info.kind == kind + assert info.number == number + assert info.is_continuation is cont + + +def test_parse_caption_rejects_noise(): + assert parse_caption_text("") is None + assert parse_caption_text("Рисунок 1 — x") is None + assert parse_caption_text("просто текст") is None + + +def test_find_page_break_row(): + assert find_page_break_row([]) is None + assert find_page_break_row([1]) is None + assert find_page_break_row([1, 1, 1]) is None + assert find_page_break_row([1, 1, 2, 2]) == 3 + assert find_page_break_row([2, 2, 3]) == 3 + assert find_page_break_row([1, 2]) == 2 + + +def test_continuation_label(): + assert continuation_label("table", "2.1") == "Продолжение Таблицы 2.1" + assert continuation_label("listing", "4") == "Продолжение Листинга 4" + + +def test_word_paged_modes_include_word(): + from md2gost.renderable.table import _WORD_PAGED_MODES as t + from md2gost.renderable.listing import _WORD_PAGED_MODES as L + assert "word" in t + assert "word" in L + + +def test_fix_continuations_missing_file(): + r = fix_continuations(os.path.join(tempfile.gettempdir(), "md2gost-no-such.docx")) + assert r.ok is False + assert "не найден" in r.message.lower() or "Файл" in r.message + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.skipif(os.environ.get("MD2GOST_TEST_WORD") != "1", reason="set MD2GOST_TEST_WORD=1 to run Word COM smoke") +def test_fix_continuations_com_smoke(tmp_path): + """Build a tall table DOCX via python-docx, then run Word fix (opt-in).""" + try: + import win32com.client # noqa: F401 + except ImportError: + pytest.skip("pywin32 not installed") + + from docx import Document + from docx.shared import Pt + + path = tmp_path / "tall_table.docx" + doc = Document() + p = doc.add_paragraph("Таблица 1 — Длинная") + try: + p.style = "Caption" + except KeyError: + pass + table = doc.add_table(rows=1, cols=2) + table.rows[0].cells[0].text = "A" + table.rows[0].cells[1].text = "B" + for i in range(80): + row = table.add_row() + row.cells[0].text = f"row {i}" + row.cells[1].text = "x" * 20 + for cell in row.cells: + for para in cell.paragraphs: + para.paragraph_format.space_after = Pt(6) + doc.save(str(path)) + + result = fix_continuations(str(path), tables=True, listings=False) + assert result.ok, result.message + assert path.is_file() diff --git a/tmp_listing_word.docx b/tmp_listing_word.docx new file mode 100644 index 0000000..700a46a Binary files /dev/null and b/tmp_listing_word.docx differ diff --git a/tmp_listing_word.md b/tmp_listing_word.md new file mode 100644 index 0000000..6133a80 --- /dev/null +++ b/tmp_listing_word.md @@ -0,0 +1,13 @@ +# *ВВЕДЕНИЕ + +Текст. + +%code1 Пример + +```bash +line1 +line2 +line3 +line4 +line5 +``` diff --git a/tmp_readme_word.docx b/tmp_readme_word.docx new file mode 100644 index 0000000..3e5f9fd Binary files /dev/null and b/tmp_readme_word.docx differ diff --git a/tmp_readme_word2.docx b/tmp_readme_word2.docx new file mode 100644 index 0000000..50da13f Binary files /dev/null and b/tmp_readme_word2.docx differ diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..ed00b42 --- /dev/null +++ b/todo.txt @@ -0,0 +1,2 @@ +- Ожидаем внедрение BPMN в mermaid +- Оценки с текстом через сам Word а не предугадываание \ No newline at end of file