Update 0.4.0
Python application / build (push) Has been cancelled

- Add\Rework UI
- Add Split Table and Listing
- Add Support Customazeble schems
This commit is contained in:
Igor20264
2026-09-04 22:28:39 +03:00
parent 516abe7b83
commit b38661f588
70 changed files with 69532 additions and 413 deletions
+9
View File
@@ -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/
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+3468
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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
+68
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,221 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean this module is required for running your program. Python and
Python 3rd-party packages include a lot of conditional or optional modules. For
example the module 'ntpath' only exists on Windows, whereas the module
'posixpath' only exists on Posix systems.
Types if import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named 'org.python' - imported by copy (optional), xml.sax (delayed, conditional)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), backports.tarfile (optional), distutils.archive_util (optional), setuptools._distutils.archive_util (optional)
missing module named pwd - imported by posixpath (delayed, conditional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), getpass (delayed), distutils.util (delayed, conditional, optional), netrc (delayed, conditional), backports.tarfile (optional), distutils.archive_util (optional), http.server (delayed, optional), webbrowser (delayed), psutil (optional), setuptools._distutils.util (delayed, conditional, optional), setuptools._distutils.archive_util (optional)
missing module named urllib.urlopen - imported by urllib (delayed, optional), lxml.html (delayed, optional)
missing module named urllib.urlencode - imported by urllib (delayed, optional), lxml.html (delayed, optional)
missing module named pep517 - imported by importlib.metadata (delayed)
missing module named posix - imported by shutil (conditional), importlib._bootstrap_external (conditional), os (conditional, optional)
missing module named resource - imported by posix (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named org - imported by pickle (optional)
missing module named pyimod02_importers - imported by C:\Users\hlebushek\AppData\Local\Programs\Python\Python310\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed), C:\Users\hlebushek\AppData\Local\Programs\Python\Python310\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgres.py (delayed)
missing module named _posixsubprocess - imported by subprocess (optional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional), psutil._compat (delayed, optional), xmlrpc.server (optional)
missing module named _manylinux - imported by packaging._manylinux (delayed, optional), setuptools._vendor.packaging._manylinux (delayed, optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named termios - imported by getpass (optional), tty (top-level), psutil._compat (delayed, optional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named 'java.lang' - imported by platform (delayed, optional), xml.sax._exceptions (conditional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named '_typeshed.importlib' - imported by pkg_resources (conditional)
missing module named _typeshed - imported by pkg_resources (conditional), setuptools.glob (conditional), wheel.wheelfile (conditional), setuptools.compat.py311 (conditional), requests.cookies (conditional), setuptools._distutils.dist (conditional)
missing module named jnius - imported by platformdirs.android (delayed, conditional, optional)
missing module named android - imported by platformdirs.android (delayed, conditional, optional)
missing module named 'distutils._modified' - imported by setuptools._distutils.file_util (delayed)
missing module named 'distutils._log' - imported by setuptools._distutils.command.bdist_dumb (top-level), setuptools._distutils.command.bdist_rpm (top-level), setuptools._distutils.command.build_clib (top-level), setuptools._distutils.command.build_ext (top-level), setuptools._distutils.command.build_py (top-level), setuptools._distutils.command.build_scripts (top-level), setuptools._distutils.command.clean (top-level), setuptools._distutils.command.config (top-level), setuptools._distutils.command.install (top-level), setuptools._distutils.command.install_scripts (top-level), setuptools._distutils.command.sdist (top-level)
missing module named usercustomize - imported by site (delayed, optional)
missing module named sitecustomize - imported by site (delayed, optional)
missing module named startup - imported by pyreadline3.keysyms.common (conditional), pyreadline3.keysyms.keysyms (conditional)
missing module named sets - imported by pyreadline3.keysyms.common (optional), pytz.tzinfo (optional)
missing module named System - imported by pyreadline3.clipboard.ironpython_clipboard (top-level), pyreadline3.keysyms.ironpython_keysyms (top-level), pyreadline3.console.ironpython_console (top-level), pyreadline3.rlmain (conditional)
missing module named console - imported by pyreadline3.console.ansi (conditional)
missing module named collections.Callable - imported by collections (optional), cffi.api (optional), socks (optional), bs4.element (optional), bs4.builder._lxml (optional)
missing module named _dummy_thread - imported by cffi.lock (conditional, optional), numpy.core.arrayprint (optional)
missing module named dummy_thread - imported by cffi.lock (conditional, optional)
missing module named thread - imported by cffi.lock (conditional, optional), cffi.cparser (conditional, optional)
missing module named cStringIO - imported by cffi.ffiplatform (optional)
missing module named cPickle - imported by pycparser.ply.yacc (delayed, optional)
missing module named cffi._pycparser - imported by cffi (optional), cffi.cparser (optional)
missing module named IronPythonConsole - imported by pyreadline3.console.ironpython_console (top-level)
missing module named tomllib - imported by setuptools.compat.py310 (conditional)
missing module named 'typing.io' - imported by importlib.resources (top-level)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _winreg - imported by platform (delayed, optional), pygments.formatters.img (optional)
missing module named pickle5 - imported by numpy.compat.py3k (optional)
missing module named numpy.eye - imported by numpy (delayed), numpy.core.numeric (delayed)
missing module named numpy.core.integer - imported by numpy.core (top-level), numpy.fft.helper (top-level)
missing module named numpy.core.conjugate - imported by numpy.core (top-level), numpy.fft._pocketfft (top-level)
missing module named numpy.core.ufunc - imported by numpy.core (top-level), numpy.lib.utils (top-level)
missing module named numpy.core.ones - imported by numpy.core (top-level), numpy.lib.polynomial (top-level)
missing module named numpy.core.hstack - imported by numpy.core (top-level), numpy.lib.polynomial (top-level)
missing module named numpy.core.atleast_1d - imported by numpy.core (top-level), numpy.lib.polynomial (top-level)
missing module named numpy.core.atleast_3d - imported by numpy.core (top-level), numpy.lib.shape_base (top-level)
missing module named numpy.core.vstack - imported by numpy.core (top-level), numpy.lib.shape_base (top-level)
missing module named numpy.core.linspace - imported by numpy.core (top-level), numpy.lib.index_tricks (top-level)
missing module named numpy.core.transpose - imported by numpy.core (top-level), numpy.lib.function_base (top-level)
missing module named numpy.core.result_type - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.float_ - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.number - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.max - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.bool_ - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.inf - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.array2string - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.signbit - imported by numpy.core (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.core.isscalar - imported by numpy.core (delayed), numpy.testing._private.utils (delayed), numpy.lib.polynomial (top-level)
missing module named numpy.core.isnat - imported by numpy.core (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.core.ndarray - imported by numpy.core (top-level), numpy.testing._private.utils (top-level), numpy.lib.utils (top-level)
missing module named numpy.core.array_repr - imported by numpy.core (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.core.arange - imported by numpy.core (top-level), numpy.testing._private.utils (top-level), numpy.fft.helper (top-level)
missing module named numpy.core.float32 - imported by numpy.core (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.core.iinfo - imported by numpy.core (top-level), numpy.lib.twodim_base (top-level)
missing module named numpy.core.reciprocal - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.sort - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.argsort - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.sign - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.isnan - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (delayed)
missing module named numpy.core.count_nonzero - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.divide - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.swapaxes - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.fft._pocketfft (top-level)
missing module named numpy.core.matmul - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.object_ - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (delayed)
missing module named numpy.core.asanyarray - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.intp - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.core.atleast_2d - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.prod - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.amax - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.amin - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.moveaxis - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.geterrobj - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.errstate - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (delayed)
missing module named numpy.core.finfo - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.lib.polynomial (top-level)
missing module named numpy.core.isfinite - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.sum - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.sqrt - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.fft._pocketfft (top-level)
missing module named numpy.core.multiply - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.add - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.dot - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.lib.polynomial (top-level)
missing module named numpy.core.Inf - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.all - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (delayed)
missing module named numpy.core.newaxis - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.complexfloating - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.inexact - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.cdouble - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.csingle - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.double - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.single - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.intc - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.empty_like - imported by numpy.core (top-level), numpy.linalg.linalg (top-level)
missing module named numpy.core.empty - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (top-level), numpy.fft.helper (top-level)
missing module named numpy.core.zeros - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.fft._pocketfft (top-level)
missing module named numpy.core.asarray - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.lib.utils (top-level), numpy.fft._pocketfft (top-level), numpy.fft.helper (top-level)
missing module named numpy.core.array - imported by numpy.core (top-level), numpy.linalg.linalg (top-level), numpy.testing._private.utils (top-level), numpy.lib.polynomial (top-level)
missing module named numpy.array - imported by numpy (top-level), numpy.ma.core (top-level), numpy.ma.extras (top-level), numpy.ma.mrecords (top-level)
missing module named numpy.dtype - imported by numpy (top-level), numpy._typing._array_like (top-level), numpy.array_api._typing (top-level), numpy.ma.mrecords (top-level), numpy.ctypeslib (top-level)
missing module named numpy.bool_ - imported by numpy (top-level), numpy._typing._array_like (top-level), numpy.ma.core (top-level), numpy.ma.mrecords (top-level)
missing module named numpy.recarray - imported by numpy (top-level), numpy.lib.recfunctions (top-level), numpy.ma.mrecords (top-level)
missing module named numpy.ndarray - imported by numpy (top-level), numpy._typing._array_like (top-level), numpy.ma.core (top-level), numpy.ma.extras (top-level), numpy.lib.recfunctions (top-level), numpy.ma.mrecords (top-level), numpy.ctypeslib (top-level)
missing module named pyodide_js - imported by threadpoolctl (delayed, optional)
missing module named numpy._typing._ufunc - imported by numpy._typing (conditional)
missing module named numpy.bytes_ - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.str_ - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.void - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.object_ - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.datetime64 - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.timedelta64 - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.number - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.complexfloating - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.floating - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.integer - imported by numpy (top-level), numpy._typing._array_like (top-level), numpy.ctypeslib (top-level)
missing module named numpy.unsignedinteger - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.generic - imported by numpy (top-level), numpy._typing._array_like (top-level)
missing module named numpy.histogramdd - imported by numpy (delayed), numpy.lib.twodim_base (delayed)
missing module named numpy.lib.imag - imported by numpy.lib (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.lib.real - imported by numpy.lib (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.lib.iscomplexobj - imported by numpy.lib (delayed), numpy.testing._private.utils (delayed)
missing module named numpy.ufunc - imported by numpy (top-level), numpy._typing (top-level), numpy.testing.overrides (top-level)
missing module named dummy_threading - imported by psutil._compat (optional)
missing module named numpy.isinf - imported by numpy (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.isnan - imported by numpy (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.isfinite - imported by numpy (top-level), numpy.testing._private.utils (top-level)
missing module named numpy.float64 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.float32 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.uint64 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.uint32 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.uint16 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.uint8 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.int64 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.int32 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.int16 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.int8 - imported by numpy (top-level), numpy.array_api._typing (top-level)
missing module named numpy.expand_dims - imported by numpy (top-level), numpy.ma.core (top-level)
missing module named numpy.iscomplexobj - imported by numpy (top-level), numpy.ma.core (top-level)
missing module named numpy.amin - imported by numpy (top-level), numpy.ma.core (top-level)
missing module named numpy.amax - imported by numpy (top-level), numpy.ma.core (top-level)
missing module named six.moves.range - imported by six.moves (top-level), dateutil.rrule (top-level)
runtime module named six.moves - imported by dateutil.tz.tz (top-level), dateutil.tz._factories (top-level), dateutil.tz.win (top-level), dateutil.rrule (top-level)
missing module named StringIO - imported by docx.compat (conditional), six (conditional), urllib3.packages.six (conditional)
missing module named dateutil.tz.tzfile - imported by dateutil.tz (top-level), dateutil.zoneinfo (top-level)
excluded module named matplotlib.backends.backend_webagg - imported by matplotlib.backends (delayed, conditional), matplotlib.figure (delayed, conditional)
missing module named railroad - imported by pyparsing.diagram (top-level)
missing module named pyparsing.Word - imported by pyparsing (delayed), pyparsing.unicode (delayed)
missing module named matplotlib.axes.Axes - imported by matplotlib.axes (delayed), matplotlib.legend (delayed), matplotlib.projections.geo (top-level), matplotlib.projections.polar (top-level), mpl_toolkits.mplot3d.axes3d (top-level), matplotlib.figure (top-level), matplotlib.pyplot (top-level)
missing module named numpy._distributor_init_local - imported by numpy (optional), numpy._distributor_init (optional)
missing module named gi - imported by matplotlib.cbook (delayed, conditional)
missing module named setuptools_scm - imported by matplotlib (delayed, conditional)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named Queue - imported by urllib3.util.queue (conditional)
missing module named 'urllib3.packages.six.moves.urllib.parse' - imported by urllib3.request (top-level), urllib3.poolmanager (top-level)
runtime module named urllib3.packages.six.moves - imported by http.client (top-level), urllib3.util.response (top-level), urllib3.connectionpool (top-level), urllib3.packages.six.moves.urllib (top-level), urllib3.util.queue (top-level)
missing module named collections.MutableMapping - imported by collections (optional), urllib3._collections (optional)
missing module named collections.Mapping - imported by collections (optional), urllib3._collections (optional), pytz.lazy (optional)
missing module named win_inet_pton - imported by socks (conditional, optional)
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
missing module named chardet - imported by requests (optional), pygments.lexer (delayed, conditional, optional), bs4.dammit (optional)
missing module named urllib3_secure_extra - imported by urllib3 (optional)
missing module named objprint - imported by marko.element (delayed, optional)
missing module named collections.Sequence - imported by collections (conditional), docx.compat (conditional)
missing module named UserDict - imported by pytz.lazy (optional)
missing module named pygments.lexers.PrologLexer - imported by pygments.lexers (top-level), pygments.lexers.cplint (top-level)
missing module named ctags - imported by pygments.formatters.html (optional)
missing module named olefile - imported by PIL.FpxImagePlugin (top-level), PIL.MicImagePlugin (top-level)
excluded module named PIL.ImageQt - imported by PIL (delayed), PIL.Image (delayed)
missing module named xmlrpclib - imported by defusedxml.xmlrpc (conditional)
missing module named htmlentitydefs - imported by lxml.html.soupparser (optional)
missing module named BeautifulSoup - imported by lxml.html.soupparser (optional)
missing module named cchardet - imported by bs4.dammit (optional)
missing module named bs4.builder.HTMLParserTreeBuilder - imported by bs4.builder (top-level), bs4 (top-level)
missing module named 'html5lib.treebuilders' - imported by bs4.builder._html5lib (optional), lxml.html._html5builder (top-level), lxml.html.html5parser (top-level)
missing module named 'html5lib.constants' - imported by bs4.builder._html5lib (top-level)
missing module named html5lib - imported by bs4.builder._html5lib (top-level), lxml.html.html5parser (top-level)
missing module named urlparse - imported by lxml.ElementInclude (optional), lxml.html.html5parser (optional)
missing module named urllib2 - imported by lxml.ElementInclude (optional), lxml.html.html5parser (optional)
missing module named lxml_html_clean - imported by lxml.html.clean (optional)
missing module named cssselect - imported by lxml.cssselect (optional)
missing module named 'win32com.gen_py' - imported by win32com (conditional, optional)
missing module named tkinterdnd2 - imported by md2gost.dnd (delayed, optional), md2gost.gui (delayed, optional)
File diff suppressed because it is too large Load Diff
+162
View File
@@ -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,
)
+123
View File
@@ -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,
)
Binary file not shown.
+135 -13
View File
@@ -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-<id>` или короткое `<id>`:
````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 (шаги 23).
Формат в 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).
+9
View File
@@ -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__))
+147 -105
View File
@@ -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__":
+105 -14
View File
@@ -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
+20 -3
View File
@@ -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(
+261
View File
@@ -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<q>[\"']?)(?P<ref>[^\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())))
+360 -69
View File
@@ -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 "<definitions" not in text.lower():
# PlantUML BPMN dialect
if not text.startswith("@start"):
text = "@startbpmn\n" + text + "\n@endbpmn"
return text, "bpmn"
if lang == "c4":
if "!include" not in text and "!includeurl" not in text.lower():
# Prefer PlantUML stdlib; also ship local stubs for jar -I path
includes = (
f"!include {diagrams_dir() / 'C4_Container.puml'}\n"
if (diagrams_dir() / "C4_Container.puml").exists()
else "!include <C4/C4_Container>\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"<svg") or head.startswith(b"<?xml") or b"<svg" in head
def _render_plantuml_jar_one(
source: str,
out_path: Path,
jar: str,
fmt: Literal["png", "svg"],
) -> 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",
]
+335
View File
@@ -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-<id> / 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"
File diff suppressed because it is too large Load Diff
+60
View File
@@ -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 : <<include>>\nUC3 .> UC1 : <<extend>>",
"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"
}
}
+326
View File
@@ -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("<<Drop>>", lambda event: deliver(getattr(event, "data", "") or ""))
bind("<<DragEnter>>", lambda event: widget.event_generate("<<Md2GostDragEnter>>"))
bind("<<DragLeave>>", lambda event: widget.event_generate("<<Md2GostDragLeave>>"))
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("<Map>", start, add="+")
widget.bind("<Destroy>", 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
+25
View File
@@ -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)
+55
View File
@@ -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"<svg") or head.startswith(b"<?xml") or b"<svg" in head):
return False
part = run.part
package = part.package
partname = package.next_partname("/word/media/image%d.svg")
svg_part = Part(partname, SVG_CONTENT_TYPE, data, package)
r_id = part.relate_to(svg_part, RT.IMAGE)
blip = inline_shape._inline.graphic.graphicData.pic.blipFill.blip
ext_lst = blip.find(qn("a:extLst"))
if ext_lst is None:
ext_lst = OxmlElement("a:extLst")
blip.append(ext_lst)
# Drop any previous svgBlip extension with the same URI.
for ext in list(ext_lst.findall(qn("a:ext"))):
if ext.get("uri") == SVG_BLIP_URI:
ext_lst.remove(ext)
ext = OxmlElement("a:ext")
ext.set("uri", SVG_BLIP_URI)
svg_blip = etree.SubElement(ext, ASVG_SVG_BLIP)
svg_blip.set(qn("r:embed"), r_id)
ext_lst.append(ext)
return True
+20 -5
View File
@@ -4,23 +4,38 @@ from re import Match, compile as re_compile
_LISTING_FLAG_RE = re_compile(r"(?i)(?:^|\s)\+?listing\b")
_LANDSCAPE_FLAG_RE = re_compile(r"(?i)(?:^|\s)\+?landscape\b")
def strip_caption_flags(raw: str) -> 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:
+7 -1
View File
@@ -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()
)
+1260
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -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-<id> / <id> из файла схем (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
+10
View File
@@ -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
+226
View File
@@ -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)
+142
View File
@@ -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")]
+8 -3
View File
@@ -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
+303
View File
@@ -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)
+8 -3
View File
@@ -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")
+6 -2
View File
@@ -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
+10 -3
View File
@@ -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)
+2 -2
View File
@@ -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()
+70 -10
View File
@@ -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)))
+4
View File
@@ -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)
+91 -32
View File
@@ -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))
+16 -8
View File
@@ -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:
+5 -3
View File
@@ -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
+6 -1
View File
@@ -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))
+24 -5
View File
@@ -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
+203 -100
View File
@@ -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 13; «СОДЕРЖАНИЕ» 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:
+40 -8
View File
@@ -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
View File
+525
View File
@@ -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<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
r"(?P<number>[\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
+40 -5
View File
@@ -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 по умолчанию тире сохраняется).
- Пробел после знака препинания, не перед ним; без пробела после «» и перед «».
- Маркированный список: единообразный маркер; пункты со **строчной** буквы, в конце `;`, у последнего `.`.
- Нумерованный список: с **прописной**, в конце `.`.
+2 -2
View File
@@ -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`.
+9
View File
@@ -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"
+461
View File
@@ -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()
+85
View File
@@ -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
+30
View File
@@ -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())
+6
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
"""Runtime hook: matplotlib only needs Agg for font_manager on a foreign PC."""
import os
os.environ.setdefault("MPLBACKEND", "Agg")
+398 -13
View File
@@ -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"<svg xmlns='http://www.w3.org/2000/svg'/>")
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"<svg/>")
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"<svg xmlns='http://www.w3.org/2000/svg'/>")
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'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
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"<svg")
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_attach_svg_blip():
from docx import Document
from md2gost.docx_svg import attach_svg_blip, SVG_BLIP_URI
doc = Document()
run = doc.add_paragraph().add_run()
# minimal valid-ish PNG (1x1)
from io import BytesIO
from PIL import Image as PILImage
buf = BytesIO()
PILImage.new("RGB", (8, 8), color=(255, 0, 0)).save(buf, format="PNG")
buf.seek(0)
picture = run.add_picture(buf)
import tempfile
with tempfile.TemporaryDirectory() as tmp:
svg = Path(tmp) / "d.svg"
svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
'<rect width="10" height="10"/></svg>',
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
+200
View File
@@ -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}"
+161
View File
@@ -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
+186
View File
@@ -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("<Motion>", 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
+3 -3
View File
@@ -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")
+110
View File
@@ -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()
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
# *ВВЕДЕНИЕ
Текст.
%code1 Пример
```bash
line1
line2
line3
line4
line5
```
Binary file not shown.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
- Ожидаем внедрение BPMN в mermaid
- Оценки с текстом через сам Word а не предугадываание