- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
This commit is contained in:
@@ -31,6 +31,8 @@ MANIFEST
|
|||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
*.manifest
|
*.manifest
|
||||||
*.spec
|
*.spec
|
||||||
|
!md2gost.spec
|
||||||
|
!md2gost.fast.spec
|
||||||
|
|
||||||
# Installer logs
|
# Installer logs
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
@@ -130,6 +132,7 @@ venv.bak/
|
|||||||
|
|
||||||
# md2gost diagram PNG cache
|
# md2gost diagram PNG cache
|
||||||
.md2gost-cache/
|
.md2gost-cache/
|
||||||
|
md2gost/vendor/*.jar
|
||||||
|
|
||||||
# LaTeX build artefacts
|
# LaTeX build artefacts
|
||||||
*_latex/
|
*_latex/
|
||||||
@@ -172,3 +175,9 @@ cython_debug/
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# 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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# md2gost runtime (created next to app / cwd on first run)
|
||||||
|
md2gost.schemes.json
|
||||||
|
md2gost.include-cache.json
|
||||||
|
include-cache/
|
||||||
|
.md2gost-cache/
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+3468
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -10,6 +10,32 @@ poetry install
|
|||||||
pip install -e .
|
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
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -31,7 +57,7 @@ python -m md2gost report.md -o report.docx --type PIS_custom --check
|
|||||||
# Титул: «Отчёт по практическим работам …» — отдельный DOCX:
|
# Титул: «Отчёт по практическим работам …» — отдельный DOCX:
|
||||||
python -m md2gost report.md -o report.docx --type PIS_custom --title title.docx
|
python -m md2gost report.md -o report.docx --type PIS_custom --title title.docx
|
||||||
# Курсовая АПИД (источники 7–20, проверка пунктов 2.1–2.4):
|
# Курсовая АПИД (источники 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).
|
Пример: [`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`)
|
### Тире (`--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` |
|
| Таблица | `%id Подпись` перед таблицей + `@Таблица:id` |
|
||||||
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
|
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
|
||||||
| Листинг | `%id Подпись` перед code fence |
|
| Листинг | `%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` (номер только при ссылке) |
|
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
|
||||||
| Источник | `[1]` в тексте; `[1]: …` в списке |
|
| Источник | `[1]` в тексте; `[1]: …` в списке |
|
||||||
|
| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) |
|
||||||
|
|
||||||
### Таблицы со склеиванием
|
### Таблицы со склеиванием
|
||||||
|
|
||||||
@@ -98,22 +129,44 @@ python -m md2gost report.md -o report.docx --toc manual
|
|||||||
Разные шапки «первый раз / продолжение» есть в LaTeX (`longtable`), не в Office.
|
Разные шапки «первый раз / продолжение» есть в LaTeX (`longtable`), не в Office.
|
||||||
|
|
||||||
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
|
Наша оценка высоты строк ≠ вёрстка 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` (могут быть дыры) |
|
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
|
||||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
|
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
|
||||||
|
|
||||||
```bash
|
```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 off
|
||||||
python -m md2gost report.md -o report.docx --table-continuation caption
|
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
|
%usecase1 Диаграмма прецедентов +listing
|
||||||
|
|
||||||
```uml
|
```uml
|
||||||
@@ -122,19 +175,86 @@ actor Student
|
|||||||
Student --> (Login)
|
Student --> (Login)
|
||||||
@enduml
|
@enduml
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Широкая схема на альбомной странице — флаг `+landscape` в той же строке `%`:
|
||||||
|
|
||||||
|
%arch1 Архитектура +landscape
|
||||||
|
|
||||||
|
```uml-c4
|
||||||
|
Person(user, "Студент")
|
||||||
|
System(app, "Портал")
|
||||||
```
|
```
|
||||||
|
|
||||||
Рендер (по приоритету):
|
````markdown
|
||||||
|
%arch1 Архитектура +landscape
|
||||||
|
|
||||||
1. `PLANTUML_JAR` / `--plantuml-jar` + Java → `plantuml.jar`
|
```uml-c4
|
||||||
2. `KROKI_URL` / `--kroki-url` (по умолчанию `http://localhost:8000`)
|
Person(user, "Студент")
|
||||||
3. remote `https://kroki.io` при `--diagram-fallback remote` (по умолчанию; предупреждение в лог)
|
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 (шаги 2–3).
|
||||||
|
|
||||||
|
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML рендерится с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
|
||||||
|
|
||||||
|
Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная.
|
||||||
|
|
||||||
|
Свой jar — только если нужен другой файл: `--plantuml-jar` или поле на вкладке «Диаграммы».
|
||||||
|
|
||||||
|
Кэш: `{каталог_md}/.md2gost-cache/` (`*.png`, при svg ещё `*.svg`).
|
||||||
|
|
||||||
```bash
|
```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-fallback local
|
||||||
|
python -m md2gost report.md -o report.docx --diagram-format svg
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробности и ИИ-промпт: [`prompts/`](../prompts/).
|
Подробности и ИИ-промпт: [`prompts/`](../prompts/).
|
||||||
@@ -144,3 +264,5 @@ PDF через LaTeX (XeLaTeX, шаблон МИРЭА): [`md2latex/README.md`](
|
|||||||
## Проверки
|
## Проверки
|
||||||
|
|
||||||
`--check` печатает замечания по структуре, «рис.», ссылкам во введении, числу/возрасту источников, приложениям и т.д. `--strict` завершает процесс с кодом 1 при ошибках.
|
`--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).
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
"""md2gost — Markdown → DOCX (MIREA TZ / GOST)."""
|
"""md2gost — Markdown → DOCX (MIREA TZ / GOST)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def package_dir() -> str:
|
def package_dir() -> str:
|
||||||
"""Directory of the md2gost package (Template.docx, mml2omml, etc.)."""
|
"""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__))
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|||||||
+147
-105
@@ -1,41 +1,44 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
from argparse import ArgumentParser, BooleanOptionalAction
|
from argparse import ArgumentParser, BooleanOptionalAction
|
||||||
import os
|
|
||||||
import os.path
|
|
||||||
import sys
|
import sys
|
||||||
from getpass import getuser
|
|
||||||
|
|
||||||
from docx import Document
|
from .pipeline import ConvertRequest, convert, should_launch_gui
|
||||||
|
|
||||||
from .converter import Converter
|
|
||||||
from .profiles import (
|
from .profiles import (
|
||||||
|
DEFAULT_HEADING_NUMBERING,
|
||||||
|
DEFAULT_TABLE_CONTINUATION,
|
||||||
|
DEFAULT_LISTING_CONTINUATION,
|
||||||
|
DEFAULT_TOC_MODE,
|
||||||
DOC_TYPES,
|
DOC_TYPES,
|
||||||
HEADING_NUMBERING_MODES,
|
HEADING_NUMBERING_MODES,
|
||||||
DEFAULT_HEADING_NUMBERING,
|
|
||||||
TOC_MODES,
|
|
||||||
DEFAULT_TOC_MODE,
|
|
||||||
TABLE_CONTINUATION_MODES,
|
TABLE_CONTINUATION_MODES,
|
||||||
DEFAULT_TABLE_CONTINUATION,
|
LISTING_CONTINUATION_MODES,
|
||||||
get_profile,
|
TOC_MODES,
|
||||||
)
|
)
|
||||||
from .checker import check_markdown, format_report
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def build_parser() -> ArgumentParser:
|
||||||
parser = ArgumentParser(
|
parser = ArgumentParser(
|
||||||
prog="md2gost",
|
prog="md2gost",
|
||||||
description=(
|
description=(
|
||||||
"Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. "
|
"Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. "
|
||||||
"Типы: coursework/practice/vkr, APID_coursework, PIS_custom. "
|
"Типы: coursework/practice/vkr, APID_coursework, PIS_custom. "
|
||||||
|
"Без файла или с --gui открывается окно. "
|
||||||
"FODT: python -m md2fodt …"
|
"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("-o", "--output", help="Путь до сгенерированного .docx")
|
||||||
parser.add_argument("-t", "--template", help="Путь до шаблона .docx")
|
parser.add_argument("-t", "--template", help="Путь до шаблона .docx")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--type", dest="doc_type", choices=DOC_TYPES, default="coursework",
|
"--type", dest="doc_type", choices=DOC_TYPES, default="practice",
|
||||||
help="Тип: coursework | practice | vkr | PIS_custom | APID_coursework",
|
help="Тип: practice | coursework | vkr | PIS_custom | APID_coursework (по умолчанию practice)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--heading-numbering",
|
"--heading-numbering",
|
||||||
@@ -64,19 +67,53 @@ def main():
|
|||||||
default=DEFAULT_TABLE_CONTINUATION,
|
default=DEFAULT_TABLE_CONTINUATION,
|
||||||
help=(
|
help=(
|
||||||
"Таблицы длиннее страницы: "
|
"Таблицы длиннее страницы: "
|
||||||
"off/soft — одна таблица, пагинация Word, без авто«Продолжение» (по умолчанию); "
|
"word — после сохранения Word COM режет по реальной пагинации + «Продолжение…» "
|
||||||
|
"(по умолчанию; нужны Windows, Word, pywin32); "
|
||||||
|
"off/soft — одна таблица, пагинация Word, без авто«Продолжение»; "
|
||||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
||||||
"(оценка ≠ Word, возможны артефакты). "
|
"(оценка ≠ Word, возможны артефакты). "
|
||||||
f"По умолчанию: {DEFAULT_TABLE_CONTINUATION}."
|
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(
|
parser.add_argument(
|
||||||
"--emdash-to-hyphen",
|
"--emdash-to-hyphen",
|
||||||
action=BooleanOptionalAction,
|
action=BooleanOptionalAction,
|
||||||
default=True,
|
default=False,
|
||||||
help=(
|
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 титульного листа (вставляется перед телом)")
|
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом)")
|
||||||
@@ -85,6 +122,15 @@ def main():
|
|||||||
action="store_true")
|
action="store_true")
|
||||||
parser.add_argument("--check-only", help="Только проверка, без генерации документа",
|
parser.add_argument("--check-only", help="Только проверка, без генерации документа",
|
||||||
action="store_true")
|
action="store_true")
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-pages",
|
||||||
|
help=(
|
||||||
|
"После конвертации (или для готового .docx) проверить полупустые страницы "
|
||||||
|
"через Microsoft Word. Эвристика — возможны ложные срабатывания. "
|
||||||
|
"Нужны Windows, Word и pywin32."
|
||||||
|
),
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
parser.add_argument("--strict", help="Код выхода 1 при ошибках проверки",
|
parser.add_argument("--strict", help="Код выхода 1 при ошибках проверки",
|
||||||
action="store_true")
|
action="store_true")
|
||||||
parser.add_argument("--syntax-highlighting", help="Подсветка синтаксиса в листингах",
|
parser.add_argument("--syntax-highlighting", help="Подсветка синтаксиса в листингах",
|
||||||
@@ -103,108 +149,104 @@ def main():
|
|||||||
default="remote",
|
default="remote",
|
||||||
help="Если локальный рендер UML недоступен: remote (kroki.io), local (ошибка), off",
|
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="Добавляет отладочные данные в документ",
|
parser.add_argument("--debug", help="Добавляет отладочные данные в документ",
|
||||||
action="store_true")
|
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
|
def request_from_args(args) -> ConvertRequest:
|
||||||
configure_diagrams(
|
return ConvertRequest(
|
||||||
plantuml_jar=args.plantuml_jar,
|
filename=args.filename or "",
|
||||||
kroki_url=args.kroki_url,
|
output=args.output,
|
||||||
fallback=args.diagram_fallback,
|
template=args.template,
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
doc_type=args.doc_type,
|
doc_type=args.doc_type,
|
||||||
heading_numbering=args.heading_numbering,
|
heading_numbering=args.heading_numbering,
|
||||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
|
||||||
toc_mode=args.toc,
|
toc_mode=args.toc,
|
||||||
table_continuation=args.table_continuation,
|
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.
|
def _argv_needs_console(argv: list[str]) -> bool:
|
||||||
# Never use title.docx as compose base: python-docx default template has
|
"""Frozen windowed exe: attach a console for CLI / --help, not for GUI."""
|
||||||
# Calibri + accent-blue headings and would override ГОСТ стили.
|
args = argv[1:]
|
||||||
if args.title or args.assignment:
|
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:
|
try:
|
||||||
from docxcompose.composer import Composer
|
sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace")
|
||||||
except ImportError:
|
except OSError:
|
||||||
print("Error: docxcompose required for --title/--assignment")
|
pass
|
||||||
sys.exit(3)
|
except Exception:
|
||||||
from .styles import apply_document_styles
|
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)
|
def main():
|
||||||
if args.title:
|
if _argv_needs_console(sys.argv):
|
||||||
composer.append(Document(args.title))
|
_enable_windows_console()
|
||||||
shell.add_page_break()
|
parser = build_parser()
|
||||||
if args.assignment:
|
args = parser.parse_args()
|
||||||
composer.append(Document(args.assignment))
|
req = request_from_args(args)
|
||||||
shell.add_page_break()
|
|
||||||
composer.append(document)
|
|
||||||
document = composer.doc
|
|
||||||
apply_document_styles(document, get_profile(args.doc_type).style_preset)
|
|
||||||
|
|
||||||
document.core_properties.author = getuser()
|
if should_launch_gui(args.filename, args.gui):
|
||||||
document.core_properties.comments = \
|
from .gui import run_gui
|
||||||
"Создано при помощи md2gost (ТЗ МИРЭА)"
|
run_gui(req)
|
||||||
|
return
|
||||||
|
|
||||||
document.save(output)
|
result = convert(req)
|
||||||
print(f"Generated document: {os.path.abspath(output)}")
|
sys.exit(result.exit_code)
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+105
-14
@@ -258,12 +258,36 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
|||||||
|
|
||||||
n = len(entries)
|
n = len(entries)
|
||||||
if profile.sectional_biblio:
|
if profile.sectional_biblio:
|
||||||
# Count per ## section roughly
|
# Count per ## section inside bibliography
|
||||||
if n < profile.min_sources:
|
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(
|
issues.append(Issue(
|
||||||
"biblio.count", "warning",
|
"biblio.count", "warning",
|
||||||
f"ВКР: в каждом разделе списка желательно ≥{profile.min_sources} источников "
|
f"ВКР: список должен делиться на разделы; в каждом ≥{profile.min_sources} "
|
||||||
f"(сейчас всего {n})",
|
f"источников (сейчас всего {n}, разделов нет)",
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
if n < profile.min_sources:
|
if n < profile.min_sources:
|
||||||
@@ -288,13 +312,10 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Citation order vs first appearance
|
# Citation order vs first appearance
|
||||||
# Exclude intro/conclusion from citation scan for order
|
|
||||||
body_for_cites = text
|
body_for_cites = text
|
||||||
cites = find_citations(body_for_cites)
|
cites = find_citations(body_for_cites)
|
||||||
# Filter cites that appear only in intro/conclusion — still listed
|
|
||||||
keys = [e[0] for e in entries]
|
keys = [e[0] for e in entries]
|
||||||
if cites and keys:
|
if cites and keys:
|
||||||
# First N unique cites should match order of keys for simple lists
|
|
||||||
if not profile.sectional_biblio:
|
if not profile.sectional_biblio:
|
||||||
expected = cites[:len(keys)]
|
expected = cites[:len(keys)]
|
||||||
if keys != expected and set(keys) == set(expected):
|
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]:
|
def check_object_refs(text: str) -> list[Issue]:
|
||||||
issues = []
|
issues = []
|
||||||
# Captions / images
|
|
||||||
labels = set(re.findall(r"^%(\w+)", text, re.M))
|
labels = set(re.findall(r"^%(\w+)", text, re.M))
|
||||||
# image titles with %id
|
|
||||||
labels.update(re.findall(r'!\[[^\]]*\]\([^)]*%(\w+)', text))
|
labels.update(re.findall(r'!\[[^\]]*\]\([^)]*%(\w+)', text))
|
||||||
refs = set(re.findall(r"@[\wА-Яа-я]+:(\w+)", text))
|
refs = set(re.findall(r"@[\wА-Яа-я]+:(\w+)", text))
|
||||||
for lab in labels:
|
for lab in labels:
|
||||||
# At least one @?:lab or word reference — soft check
|
if lab not in refs:
|
||||||
if lab not in refs and f"@{lab}" not in text:
|
issues.append(Issue(
|
||||||
# only warn if label looks intentional
|
"ref.unused", "warning",
|
||||||
pass
|
f"Метка «{lab}» объявлена, но в тексте нет ссылки @…:{lab}",
|
||||||
|
))
|
||||||
for ref in refs:
|
for ref in refs:
|
||||||
if ref not in labels:
|
if ref not in labels:
|
||||||
issues.append(Issue(
|
issues.append(Issue(
|
||||||
@@ -342,6 +362,66 @@ def check_appendices(text: str) -> list[Issue]:
|
|||||||
f"Буква «{letter}» не используется для обозначения приложений",
|
f"Буква «{letter}» не используется для обозначения приложений",
|
||||||
_line_of(text, m.start()),
|
_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
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@@ -404,7 +484,13 @@ def check_table_merge(text: str) -> list[Issue]:
|
|||||||
return issues
|
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)
|
profile = get_profile(doc_type)
|
||||||
issues: list[Issue] = []
|
issues: list[Issue] = []
|
||||||
issues.extend(check_structure(text, profile))
|
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_object_refs(text))
|
||||||
issues.extend(check_appendices(text))
|
issues.extend(check_appendices(text))
|
||||||
issues.extend(check_table_merge(text))
|
issues.extend(check_table_merge(text))
|
||||||
|
issues.extend(check_continuation_hints(
|
||||||
|
text,
|
||||||
|
table_continuation=table_continuation,
|
||||||
|
listing_continuation=listing_continuation,
|
||||||
|
))
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -15,7 +15,9 @@ from .profiles import (
|
|||||||
DEFAULT_TOC_MODE,
|
DEFAULT_TOC_MODE,
|
||||||
TOC_MODES,
|
TOC_MODES,
|
||||||
DEFAULT_TABLE_CONTINUATION,
|
DEFAULT_TABLE_CONTINUATION,
|
||||||
|
DEFAULT_LISTING_CONTINUATION,
|
||||||
TABLE_CONTINUATION_MODES,
|
TABLE_CONTINUATION_MODES,
|
||||||
|
LISTING_CONTINUATION_MODES,
|
||||||
)
|
)
|
||||||
from .label_pass import (
|
from .label_pass import (
|
||||||
assign_numbers,
|
assign_numbers,
|
||||||
@@ -26,6 +28,8 @@ from .label_pass import (
|
|||||||
from .renderable.heading import Heading
|
from .renderable.heading import Heading
|
||||||
from .renderable.toc import ToC
|
from .renderable.toc import ToC
|
||||||
from .renderable.table import Table
|
from .renderable.table import Table
|
||||||
|
from .renderable.listing import Listing
|
||||||
|
from .renderable.diagram import DiagramFigure
|
||||||
|
|
||||||
|
|
||||||
class Converter:
|
class Converter:
|
||||||
@@ -33,11 +37,13 @@ class Converter:
|
|||||||
|
|
||||||
def __init__(self, input_path: str, output_path: str,
|
def __init__(self, input_path: str, output_path: str,
|
||||||
template_path: str = None, debug: bool = False,
|
template_path: str = None, debug: bool = False,
|
||||||
doc_type: str = "coursework",
|
doc_type: str = "practice",
|
||||||
heading_numbering: str = DEFAULT_HEADING_NUMBERING,
|
heading_numbering: str = DEFAULT_HEADING_NUMBERING,
|
||||||
emdash_to_hyphen: bool = False,
|
emdash_to_hyphen: bool = False,
|
||||||
toc_mode: str = DEFAULT_TOC_MODE,
|
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:
|
if heading_numbering not in HEADING_NUMBERING_MODES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
|
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"table_continuation must be one of {TABLE_CONTINUATION_MODES}, "
|
||||||
f"got {table_continuation!r}"
|
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._output_path = output_path
|
||||||
self._doc_type = doc_type
|
self._doc_type = doc_type
|
||||||
self._heading_numbering = heading_numbering
|
self._heading_numbering = heading_numbering
|
||||||
self._emdash_to_hyphen = emdash_to_hyphen
|
self._emdash_to_hyphen = emdash_to_hyphen
|
||||||
self._toc_mode = toc_mode
|
self._toc_mode = toc_mode
|
||||||
self._table_continuation = table_continuation
|
self._table_continuation = table_continuation
|
||||||
|
self._listing_continuation = listing_continuation
|
||||||
|
self._hr_pagebreak = hr_pagebreak
|
||||||
self._profile = get_profile(doc_type)
|
self._profile = get_profile(doc_type)
|
||||||
self._document: Document = docx.Document(template_path)
|
self._document: Document = docx.Document(template_path)
|
||||||
self._document._body.clear_content()
|
self._document._body.clear_content()
|
||||||
@@ -67,7 +80,7 @@ class Converter:
|
|||||||
raw = f.read()
|
raw = f.read()
|
||||||
self._raw_markdown = raw
|
self._raw_markdown = raw
|
||||||
self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen)
|
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):
|
def convert(self):
|
||||||
renderables = list(self.parser.parse())
|
renderables = list(self.parser.parse())
|
||||||
@@ -85,6 +98,10 @@ class Converter:
|
|||||||
r.set_toc_mode(self._toc_mode)
|
r.set_toc_mode(self._toc_mode)
|
||||||
elif isinstance(r, Table):
|
elif isinstance(r, Table):
|
||||||
r.set_continuation_mode(self._table_continuation)
|
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)
|
formula_refs = find_formula_refs(self._raw_markdown)
|
||||||
registry = assign_numbers(
|
registry = assign_numbers(
|
||||||
|
|||||||
@@ -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
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -15,13 +17,45 @@ from typing import Literal
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from . import package_dir
|
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"]
|
FallbackMode = Literal["local", "remote", "off"]
|
||||||
|
DiagramFormat = Literal["png", "svg"]
|
||||||
|
|
||||||
DEFAULT_KROKI_URL = "http://localhost:8000"
|
DEFAULT_KROKI_URL = "http://localhost:8000"
|
||||||
REMOTE_KROKI_URL = "https://kroki.io"
|
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__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +65,17 @@ class DiagramConfig:
|
|||||||
kroki_url: str | None = None
|
kroki_url: str | None = None
|
||||||
fallback: FallbackMode = "remote"
|
fallback: FallbackMode = "remote"
|
||||||
cache_dir: str | None = None
|
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()
|
_CONFIG = DiagramConfig()
|
||||||
@@ -41,13 +86,26 @@ def configure_diagrams(
|
|||||||
kroki_url: str | None = None,
|
kroki_url: str | None = None,
|
||||||
fallback: FallbackMode = "remote",
|
fallback: FallbackMode = "remote",
|
||||||
cache_dir: str | None = None,
|
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:
|
) -> None:
|
||||||
global _CONFIG
|
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(
|
_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"),
|
kroki_url=kroki_url or os.environ.get("KROKI_URL"),
|
||||||
fallback=fallback,
|
fallback=fallback,
|
||||||
cache_dir=cache_dir,
|
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]:
|
def prepare_source(lang: str, source: str) -> tuple[str, str]:
|
||||||
"""Return (prepared_source, kroki_diagram_type)."""
|
"""Return (prepared_source, kroki_diagram_type)."""
|
||||||
lang = (lang or "uml").lower().strip()
|
return prepare_with_schemes(lang, source)
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
root = cache_dir or _CONFIG.cache_dir
|
||||||
if not root:
|
if not root:
|
||||||
wd = os.environ.get("WORKING_DIR", ".")
|
wd = os.environ.get("WORKING_DIR", ".")
|
||||||
root = os.path.join(wd, ".md2gost-cache")
|
root = os.path.join(wd, ".md2gost-cache")
|
||||||
Path(root).mkdir(parents=True, exist_ok=True)
|
path = Path(root)
|
||||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return Path(root) / f"{digest}.png"
|
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:
|
def _java_available() -> bool:
|
||||||
return shutil.which("java") is not None
|
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():
|
if not jar or not os.path.isfile(jar) or not _java_available():
|
||||||
return False
|
return False
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
src = Path(tmp) / "diagram.puml"
|
src = Path(tmp) / "diagram.puml"
|
||||||
src.write_text(source, encoding="utf-8")
|
src.write_text(source, encoding="utf-8")
|
||||||
cmd = [
|
cmd = [
|
||||||
"java", "-jar", jar,
|
"java",
|
||||||
"-tpng",
|
f"-Dplantuml.include.path={diagrams_dir().resolve()}",
|
||||||
|
f"-DPLANTUML_LIMIT_SIZE={PLANTUML_LIMIT_SIZE}",
|
||||||
|
"-jar", jar,
|
||||||
|
f"-t{fmt}",
|
||||||
"-charset", "UTF-8",
|
"-charset", "UTF-8",
|
||||||
f"-I{diagrams_dir()}",
|
|
||||||
"-o", tmp,
|
"-o", tmp,
|
||||||
str(src),
|
str(src),
|
||||||
]
|
]
|
||||||
@@ -126,16 +294,43 @@ def _render_plantuml_jar(source: str, out_png: Path, jar: str) -> bool:
|
|||||||
except (OSError, subprocess.TimeoutExpired) as e:
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
_log.warning("PlantUML jar failed: %s", e)
|
_log.warning("PlantUML jar failed: %s", e)
|
||||||
return False
|
return False
|
||||||
produced = Path(tmp) / "diagram.png"
|
produced = Path(tmp) / f"diagram.{fmt}"
|
||||||
if proc.returncode != 0 or not produced.is_file():
|
if proc.returncode != 0 or not produced.is_file():
|
||||||
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
|
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path) -> bool:
|
def _render_plantuml_jar(
|
||||||
url = base_url.rstrip("/") + f"/{diagram_type}/png"
|
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:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
url,
|
url,
|
||||||
@@ -143,16 +338,38 @@ def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path)
|
|||||||
headers={"Content-Type": "text/plain"},
|
headers={"Content-Type": "text/plain"},
|
||||||
timeout=60,
|
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)
|
_log.warning("Kroki %s → HTTP %s", url, resp.status_code)
|
||||||
return False
|
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
|
return True
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
_log.warning("Kroki request failed (%s): %s", url, e)
|
_log.warning("Kroki request failed (%s): %s", url, e)
|
||||||
return False
|
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(
|
def render_diagram(
|
||||||
lang: str,
|
lang: str,
|
||||||
source: str,
|
source: str,
|
||||||
@@ -161,36 +378,91 @@ def render_diagram(
|
|||||||
kroki_url: str | None = None,
|
kroki_url: str | None = None,
|
||||||
fallback: FallbackMode | None = None,
|
fallback: FallbackMode | None = None,
|
||||||
cache_dir: str | None = None,
|
cache_dir: str | None = None,
|
||||||
) -> str:
|
diagram_format: DiagramFormat | None = None,
|
||||||
"""Render diagram to PNG path. Raises RuntimeError if all backends fail."""
|
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)
|
prepared, kroki_type = prepare_source(lang, source)
|
||||||
out = _cache_path(prepared, cache_dir)
|
scale = _CONFIG.diagram_scale if diagram_scale is None else float(diagram_scale)
|
||||||
if out.is_file() and out.stat().st_size > 0:
|
if scale < 1:
|
||||||
return str(out)
|
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 = 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 = 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
|
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
|
mode: FallbackMode = fallback if fallback is not None else _CONFIG.fallback
|
||||||
|
|
||||||
# 1) PlantUML jar
|
def _result() -> DiagramRenderResult:
|
||||||
if _render_plantuml_jar(prepared, out, jar or ""):
|
svg = None
|
||||||
return str(out)
|
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
|
# 2) Local Kroki
|
||||||
if _render_kroki(prepared, kroki_type, local_kroki, out):
|
if _render_kroki_pair(prepared, kroki_type, local_kroki, out_png, svg_target):
|
||||||
return str(out)
|
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":
|
if mode == "off":
|
||||||
raise RuntimeError(
|
if kroki_type == "plantuml":
|
||||||
"Не удалось отрендерить диаграмму локально "
|
return _png_fallback_or_raise(
|
||||||
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
|
"Не удалось отрендерить диаграмму локально "
|
||||||
|
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
|
||||||
|
)
|
||||||
|
return _png_fallback_or_raise(
|
||||||
|
"Не удалось отрендерить диаграмму через локальный Kroki "
|
||||||
|
"(задайте --kroki-url / KROKI_URL; remote fallback отключён)"
|
||||||
)
|
)
|
||||||
if mode == "local":
|
if mode == "local":
|
||||||
raise RuntimeError(
|
if kroki_type == "plantuml":
|
||||||
"Локальный рендер диаграммы недоступен "
|
return _png_fallback_or_raise(
|
||||||
"(Java+plantuml.jar или локальный Kroki)"
|
"Локальный рендер диаграммы недоступен "
|
||||||
|
"(Java+plantuml.jar или локальный Kroki)"
|
||||||
|
)
|
||||||
|
return _png_fallback_or_raise(
|
||||||
|
"Локальный Kroki недоступен для диаграммы "
|
||||||
|
f"(тип {kroki_type}; задайте работающий --kroki-url)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3) Remote fallback
|
# 3) Remote fallback
|
||||||
@@ -198,7 +470,26 @@ def render_diagram(
|
|||||||
"Диаграмма: локальный рендер недоступен, использую remote %s",
|
"Диаграмма: локальный рендер недоступен, использую remote %s",
|
||||||
REMOTE_KROKI_URL,
|
REMOTE_KROKI_URL,
|
||||||
)
|
)
|
||||||
if _render_kroki(prepared, kroki_type, REMOTE_KROKI_URL, out):
|
if _render_kroki_pair(prepared, kroki_type, REMOTE_KROKI_URL, out_png, svg_target):
|
||||||
return str(out)
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -24,6 +24,7 @@ __all__ = [
|
|||||||
"create_table_row",
|
"create_table_row",
|
||||||
"create_table_cell",
|
"create_table_cell",
|
||||||
"apply_cell_merge",
|
"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"))
|
tcPr.append(create_element("w:vMerge"))
|
||||||
if grid_span and grid_span > 1:
|
if grid_span and grid_span > 1:
|
||||||
tcPr.append(create_element("w:gridSpan", {"w:val": str(grid_span)}))
|
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)
|
||||||
@@ -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
|
||||||
@@ -4,23 +4,38 @@ from re import Match, compile as re_compile
|
|||||||
|
|
||||||
|
|
||||||
_LISTING_FLAG_RE = re_compile(r"(?i)(?:^|\s)\+?listing\b")
|
_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):
|
class Caption(BlockElement):
|
||||||
"""Represents caption element
|
"""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+)( (.+))?"
|
pattern = r"\%(\w+)( (.+))?"
|
||||||
|
|
||||||
def __init__(self, match: Match[str]):
|
def __init__(self, match: Match[str]):
|
||||||
self.unique_name = match.group(1)
|
self.unique_name = match.group(1)
|
||||||
raw = (match.group(3) or "").strip()
|
raw = (match.group(3) or "").strip()
|
||||||
self.with_listing = bool(_LISTING_FLAG_RE.search(raw))
|
self.text, self.with_listing, self.landscape = strip_caption_flags(raw)
|
||||||
if self.with_listing:
|
|
||||||
raw = _LISTING_FLAG_RE.sub(" ", raw).strip()
|
|
||||||
self.text = raw or None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def match(cls, source: Source) -> Match[str] | None:
|
def match(cls, source: Source) -> Match[str] | None:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import re
|
|||||||
|
|
||||||
from marko.inline import Image as Image_
|
from marko.inline import Image as Image_
|
||||||
|
|
||||||
|
from .caption import strip_caption_flags
|
||||||
|
|
||||||
|
|
||||||
class Image(Image_):
|
class Image(Image_):
|
||||||
override = True
|
override = True
|
||||||
@@ -9,7 +11,11 @@ class Image(Image_):
|
|||||||
super().__init__(match)
|
super().__init__(match)
|
||||||
|
|
||||||
self.unique_name = None
|
self.unique_name = None
|
||||||
|
self.with_listing = False
|
||||||
|
self.landscape = False
|
||||||
|
|
||||||
if self.title and (m := re.match(r"\%(\w+)( (.+))?", self.title)):
|
if self.title and (m := re.match(r"\%(\w+)( (.+))?", self.title)):
|
||||||
self.unique_name = m.group(1)
|
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
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
|

|
||||||
|
В тексте пишите «Рисунок», не «рис.»
|
||||||
|
|
||||||
|
Таблица
|
||||||
|
%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
|
||||||
@@ -51,3 +51,13 @@ class LayoutTracker:
|
|||||||
|
|
||||||
def new_page(self):
|
def new_page(self):
|
||||||
self._state.new_page()
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from marko.block import BlankLine
|
from marko.block import BlankLine, ThematicBreak
|
||||||
|
|
||||||
from .extended_markdown import markdown, Caption
|
from .extended_markdown import markdown, Caption
|
||||||
from .renderable.caption import CaptionInfo
|
from .renderable.caption import CaptionInfo
|
||||||
@@ -12,13 +12,14 @@ from .renderable_factory import RenderableFactory
|
|||||||
class Parser:
|
class Parser:
|
||||||
"""Parses given markdown string and returns Renderable elements"""
|
"""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._document = document
|
||||||
self._parsed = markdown.parse(text)
|
self._parsed = markdown.parse(text)
|
||||||
self._caption_info: CaptionInfo | None = None
|
self._caption_info: CaptionInfo | None = None
|
||||||
|
self._hr_pagebreak = hr_pagebreak
|
||||||
|
|
||||||
def parse(self) -> Generator[Renderable, None, None]:
|
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:
|
for marko_element in self._parsed.children:
|
||||||
if isinstance(marko_element, BlankLine):
|
if isinstance(marko_element, BlankLine):
|
||||||
@@ -29,8 +30,12 @@ class Parser:
|
|||||||
marko_element.unique_name,
|
marko_element.unique_name,
|
||||||
marko_element.text,
|
marko_element.text,
|
||||||
getattr(marko_element, "with_listing", False),
|
getattr(marko_element, "with_listing", False),
|
||||||
|
getattr(marko_element, "landscape", False),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if isinstance(marko_element, ThematicBreak) and not self._hr_pagebreak:
|
||||||
|
continue
|
||||||
|
|
||||||
yield factory.create(marko_element, self._caption_info)
|
yield factory.create(marko_element, self._caption_info)
|
||||||
self._caption_info = None
|
self._caption_info = None
|
||||||
|
|||||||
@@ -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
@@ -19,12 +19,17 @@ DEFAULT_HEADING_NUMBERING = "manual"
|
|||||||
TOC_MODES = ("manual", "native")
|
TOC_MODES = ("manual", "native")
|
||||||
DEFAULT_TOC_MODE = "native"
|
DEFAULT_TOC_MODE = "native"
|
||||||
|
|
||||||
# off — одна таблица Word, пагинацию делает Word (по умолчанию; без автоподписи)
|
# off — одна таблица Word, пагинацию делает Word (без автоподписи)
|
||||||
# legacy — режем по оценке высоты + «Продолжение…» с page_break_before
|
# legacy — режем по оценке высоты + «Продолжение…» с page_break_before
|
||||||
# caption — режем по оценке + явный PageBreak + «Продолжение…»
|
# caption — режем по оценке + явный PageBreak + «Продолжение…»
|
||||||
# soft — синоним off (автоподпись mid-page без точной вёрстки Word невозможна в DOCX)
|
# soft — синоним off (автоподпись mid-page без точной вёрстки Word невозможна в DOCX)
|
||||||
TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft")
|
# word — как off при рендере; после save Word COM режет по реальной пагинации + «Продолжение…»
|
||||||
DEFAULT_TABLE_CONTINUATION = "off"
|
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 (ПИС)
|
# section — Рисунок 1.1 / 2.1 (ТЗ МИРЭА); continuous — Рисунок 1, 2, 3 (ПИС)
|
||||||
NUMBERING_SCOPES = ("section", "continuous")
|
NUMBERING_SCOPES = ("section", "continuous")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from ..util import create_element
|
|||||||
# Map category → Word style name
|
# Map category → Word style name
|
||||||
CAPTION_STYLES = {
|
CAPTION_STYLES = {
|
||||||
"Рисунок": "Caption Figure",
|
"Рисунок": "Caption Figure",
|
||||||
"Таблица": "Caption Table",
|
"Таблица": "Название таблицы",
|
||||||
"Листинг": "Caption Listing",
|
"Листинг": "Caption Listing",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ class CaptionInfo:
|
|||||||
unique_name: str | None
|
unique_name: str | None
|
||||||
text: str | None
|
text: str | None
|
||||||
with_listing: bool = False
|
with_listing: bool = False
|
||||||
|
landscape: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Caption(Renderable):
|
class Caption(Renderable):
|
||||||
@@ -40,7 +41,10 @@ class Caption(Renderable):
|
|||||||
try:
|
try:
|
||||||
self._docx_paragraph.style = style_name
|
self._docx_paragraph.style = style_name
|
||||||
except KeyError:
|
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)
|
# Format: «Рисунок 1.1 — Название» (or « - » if --emdash-to-hyphen)
|
||||||
from ..profiles import dash_separator
|
from ..profiles import dash_separator
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from ..rendered_info import RenderedInfo
|
|||||||
|
|
||||||
|
|
||||||
class DiagramFigure(Renderable, RequiresNumbering):
|
class DiagramFigure(Renderable, RequiresNumbering):
|
||||||
"""UML/BPMN/C4 fence → PNG figure (+ optional source listing)."""
|
"""UML / Mermaid / scheme fence → figure (+ optional source listing)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -34,6 +34,7 @@ class DiagramFigure(Renderable, RequiresNumbering):
|
|||||||
)
|
)
|
||||||
self._number = None
|
self._number = None
|
||||||
self._listing_number = None
|
self._listing_number = None
|
||||||
|
self.landscape = bool(caption_info and caption_info.landscape)
|
||||||
if caption_info and caption_info.unique_name:
|
if caption_info and caption_info.unique_name:
|
||||||
self.unique_name = 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) \
|
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
|
||||||
-> Generator[RenderedInfo, None, None]:
|
-> Generator[RenderedInfo, None, None]:
|
||||||
png_path = render_diagram(self._lang, self._source)
|
result = render_diagram(self._lang, self._source)
|
||||||
image = Image(self._parent, png_path, self._caption_info)
|
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:
|
if self._number is not None:
|
||||||
image.set_number(self._number)
|
image.set_number(self._number)
|
||||||
yield from image.render(previous_rendered, layout_state)
|
yield from image.render(previous_rendered, layout_state)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from copy import copy
|
|||||||
from typing import Generator
|
from typing import Generator
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
|
||||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||||
from docx.shared import Parented, Length
|
from docx.shared import Parented, Length
|
||||||
|
|
||||||
@@ -35,7 +34,8 @@ class Heading(Paragraph):
|
|||||||
|
|
||||||
if not numbered:
|
if not numbered:
|
||||||
self._remove_numbering()
|
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":
|
elif numbering_mode == "manual":
|
||||||
# Digits already in markdown text — kill Word list numbering to avoid "1 1 …"
|
# Digits already in markdown text — kill Word list numbering to avoid "1 1 …"
|
||||||
self._remove_numbering()
|
self._remove_numbering()
|
||||||
|
|||||||
+70
-10
@@ -7,20 +7,32 @@ import os.path
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
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 docx.text.paragraph import Paragraph
|
||||||
|
|
||||||
from .caption import Caption, CaptionInfo
|
from .caption import Caption, CaptionInfo
|
||||||
from .renderable import Renderable
|
from .renderable import Renderable
|
||||||
from .requires_numbering import RequiresNumbering
|
from .requires_numbering import RequiresNumbering
|
||||||
|
from ..docx_svg import attach_svg_blip
|
||||||
from ..layout_tracker import LayoutState
|
from ..layout_tracker import LayoutState
|
||||||
from ..rendered_info import RenderedInfo
|
from ..rendered_info import RenderedInfo
|
||||||
from ..sub_renderable import SubRenderable
|
from ..sub_renderable import SubRenderable
|
||||||
from ..util import create_element
|
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):
|
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__("Рисунок")
|
super().__init__("Рисунок")
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
self._caption_info = caption_info
|
self._caption_info = caption_info
|
||||||
@@ -31,6 +43,8 @@ class Image(Renderable, RequiresNumbering):
|
|||||||
self._docx_paragraph.paragraph_format.line_spacing = 1
|
self._docx_paragraph.paragraph_format.line_spacing = 1
|
||||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||||
self._invalid = False
|
self._invalid = False
|
||||||
|
self._native_width: Length | None = None
|
||||||
|
self._native_height: Length | None = None
|
||||||
|
|
||||||
run = self._docx_paragraph.add_run()
|
run = self._docx_paragraph.add_run()
|
||||||
|
|
||||||
@@ -47,36 +61,82 @@ class Image(Renderable, RequiresNumbering):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logging.warning(f"Invalid image path: {path}, skipping...")
|
logging.warning(f"Invalid image path: {path}, skipping...")
|
||||||
self._invalid = True
|
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._number = None
|
||||||
|
self.landscape = bool(caption_info and caption_info.landscape)
|
||||||
if caption_info and caption_info.unique_name:
|
if caption_info and caption_info.unique_name:
|
||||||
self.unique_name = 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):
|
def set_number(self, number: str):
|
||||||
self._number = number
|
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)\
|
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||||
if self._invalid:
|
if self._invalid:
|
||||||
yield from []
|
yield from []
|
||||||
return
|
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
|
# 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
|
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)
|
self._image.height = Length(self._image.width * height_by_width)
|
||||||
|
|
||||||
# limit height
|
# limit height (leave room for caption on the same page)
|
||||||
if self._image.height > layout_state.max_height:
|
if self._image.height > max_h:
|
||||||
width_by_height = self._image.width / self._image.height
|
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)
|
self._image.width = Length(self._image.height * width_by_height)
|
||||||
|
|
||||||
height = self._image.height
|
height = self._image.height
|
||||||
|
need = Length(int(height) + int(caption_reserve))
|
||||||
if layout_state.remaining_page_height < height:
|
remaining = layout_state.remaining_page_height
|
||||||
height += 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)))
|
yield (rendered_image := RenderedInfo(self._docx_paragraph, Length(height)))
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ class List(Renderable):
|
|||||||
if self._paragraphs:
|
if self._paragraphs:
|
||||||
self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after
|
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 paragraph in self._paragraphs:
|
||||||
for x in paragraph.render(previous_rendered, copy(layout_state)):
|
for x in paragraph.render(previous_rendered, copy(layout_state)):
|
||||||
layout_state.add_height(x.height)
|
layout_state.add_height(x.height)
|
||||||
|
|||||||
@@ -2,23 +2,25 @@ from copy import copy
|
|||||||
import os
|
import os
|
||||||
from typing import Generator, Callable
|
from typing import Generator, Callable
|
||||||
|
|
||||||
from docx.oxml import CT_Tbl
|
|
||||||
from docx.shared import Length, Pt, RGBColor, Twips
|
from docx.shared import Length, Pt, RGBColor, Twips
|
||||||
from docx.table import Table
|
|
||||||
|
|
||||||
from pygments import highlight
|
from pygments import highlight
|
||||||
from pygments.formatter import Formatter
|
from pygments.formatter import Formatter
|
||||||
from pygments.lexers import get_lexer_by_name
|
from pygments.lexers import get_lexer_by_name
|
||||||
|
|
||||||
from .caption import Caption, CaptionInfo
|
from .caption import Caption, CaptionInfo
|
||||||
|
from .page_break import PageBreak
|
||||||
from .paragraph import Paragraph
|
from .paragraph import Paragraph
|
||||||
from .renderable import Renderable
|
from .renderable import Renderable
|
||||||
from .requires_numbering import RequiresNumbering
|
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 ..layout_tracker import LayoutState
|
||||||
|
from ..profiles import DEFAULT_LISTING_CONTINUATION, LISTING_CONTINUATION_MODES
|
||||||
from ..rendered_info import RenderedInfo
|
from ..rendered_info import RenderedInfo
|
||||||
from ..sub_renderable import SubRenderable
|
from ..sub_renderable import SubRenderable
|
||||||
|
|
||||||
|
_WORD_PAGED_MODES = frozenset({"off", "soft", "word"})
|
||||||
|
|
||||||
|
|
||||||
class DocxParagraphPygmentsFormatter(Formatter):
|
class DocxParagraphPygmentsFormatter(Formatter):
|
||||||
def __init__(self, paragraphs: list[Paragraph], creator: Callable[[], Paragraph], **options):
|
def __init__(self, paragraphs: list[Paragraph], creator: Callable[[], Paragraph], **options):
|
||||||
@@ -55,13 +57,14 @@ class Listing(Renderable, RequiresNumbering):
|
|||||||
self._caption_info = caption_info
|
self._caption_info = caption_info
|
||||||
self._language = language
|
self._language = language
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
|
self._continuation_mode = DEFAULT_LISTING_CONTINUATION
|
||||||
self.paragraphs: list[Paragraph] = []
|
self.paragraphs: list[Paragraph] = []
|
||||||
self._number = None
|
self._number = None
|
||||||
if caption_info and caption_info.unique_name:
|
if caption_info and caption_info.unique_name:
|
||||||
self.unique_name = caption_info.unique_name
|
self.unique_name = caption_info.unique_name
|
||||||
|
|
||||||
def _create_table(self, parent, width: Length):
|
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(
|
left_margin = Twips(int(
|
||||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||||
@@ -94,6 +97,51 @@ class Listing(Renderable, RequiresNumbering):
|
|||||||
def set_number(self, number: str):
|
def set_number(self, number: str):
|
||||||
self._number = number
|
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)\
|
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||||
caption_rendered_infos = list(
|
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]))
|
layout_state.add_height(sum([info.height for info in caption_rendered_infos]))
|
||||||
yield from 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
|
previous = None
|
||||||
|
|
||||||
table_height = Pt(1) # table borders, 4 eights of point for each border
|
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
|
# legacy/caption: if first line doesn't fit, burn the rest of the page
|
||||||
paragraph_layout_state = copy(layout_state)
|
if self.paragraphs and self._continuation_mode not in _WORD_PAGED_MODES:
|
||||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
paragraph_layout_state = copy(layout_state)
|
||||||
paragraph_rendered_info = next(self.paragraphs[0].render(previous, paragraph_layout_state))
|
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||||
if paragraph_rendered_info.height + table_height > layout_state.remaining_page_height:
|
first = next(self.paragraphs[0].render(previous, paragraph_layout_state))
|
||||||
table_height += layout_state.remaining_page_height
|
if first.height + table_height > layout_state.remaining_page_height:
|
||||||
layout_state.add_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:
|
for paragraph in self.paragraphs:
|
||||||
paragraph_layout_state = copy(layout_state)
|
paragraph_layout_state = copy(layout_state)
|
||||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||||
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
||||||
|
|
||||||
if paragraph_rendered_info.height > layout_state.remaining_page_height: # todo add before after
|
if self._should_split_fragment(
|
||||||
table_rendered_info = RenderedInfo(table, table_height)
|
paragraph_rendered_info.height, layout_state, lines_in_fragment
|
||||||
yield table_rendered_info
|
):
|
||||||
|
yield RenderedInfo(table, table_height)
|
||||||
table_height = Pt(1) # table borders, 4 eights of point for each border
|
yield from self._emit_page_break_and_optional_caption(layout_state)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
table_height = Pt(1)
|
||||||
|
table = create_table(self._parent, 0, 1, col_w)
|
||||||
|
set_table_box_borders(table)
|
||||||
previous = None
|
previous = None
|
||||||
|
lines_in_fragment = 0
|
||||||
|
|
||||||
paragraph_layout_state = copy(layout_state)
|
paragraph_layout_state = copy(layout_state)
|
||||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||||
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
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)
|
layout_state.add_height(paragraph_rendered_info.height)
|
||||||
table_height += paragraph_rendered_info.height
|
table_height += paragraph_rendered_info.height
|
||||||
|
lines_in_fragment += 1
|
||||||
|
|
||||||
previous = paragraph_rendered_info
|
previous = paragraph_rendered_info
|
||||||
|
|
||||||
yield RenderedInfo(table, table_height)
|
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))
|
||||||
|
|||||||
@@ -89,14 +89,16 @@ class Paragraph(Renderable):
|
|||||||
return link
|
return link
|
||||||
|
|
||||||
def add_inline_equation(self, formula: str):
|
def add_inline_equation(self, formula: str):
|
||||||
# omml = inline_omml(latex_to_omml(formula))
|
try:
|
||||||
# for r in omml.xpath("//m:r", namespaces=omml.nsmap):
|
omml = inline_omml(latex_to_omml(formula))
|
||||||
# r.append(create_element("w:rPr", [
|
for r in omml.xpath("//m:r", namespaces=omml.nsmap):
|
||||||
# create_element("w:sz", {"w:val": "24"}),
|
r.append(create_element("w:rPr", [
|
||||||
# create_element("w:szCs", {"w:val": "24"}),
|
create_element("w:sz", {"w:val": "28"}),
|
||||||
# ]))
|
create_element("w:szCs", {"w:val": "28"}),
|
||||||
# self._docx_paragraph._element.append(omml)
|
]))
|
||||||
self.add_run(formula, is_italic=True)
|
self._docx_paragraph._element.append(omml)
|
||||||
|
except Exception:
|
||||||
|
self.add_run(formula, is_italic=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def page_break_before(self) -> bool:
|
def page_break_before(self) -> bool:
|
||||||
@@ -170,6 +172,12 @@ class Paragraph(Renderable):
|
|||||||
images = iter(self._images)
|
images = iter(self._images)
|
||||||
|
|
||||||
for image in 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 = list(image.render(previous_rendered, copy(layout_state)))
|
||||||
rendered_image_height = sum([x.height for x in rendered_image])
|
rendered_image_height = sum([x.height for x in rendered_image])
|
||||||
if rendered_image:
|
if rendered_image:
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ CELL_OFFSET = Pt(9) - Twips(108 * 2)
|
|||||||
# Slack only for modes that fragment by our height estimate (legacy/caption).
|
# Slack only for modes that fragment by our height estimate (legacy/caption).
|
||||||
ROW_HEIGHT_SLACK = Pt(4)
|
ROW_HEIGHT_SLACK = Pt(4)
|
||||||
|
|
||||||
# Modes that do NOT cut the table into fragments — Word owns page breaks.
|
# Modes that do NOT cut the table into fragments — Word owns page breaks
|
||||||
_WORD_PAGED_MODES = frozenset({"off", "soft"})
|
# (word: post-split via COM after save).
|
||||||
|
_WORD_PAGED_MODES = frozenset({"off", "soft", "word"})
|
||||||
|
|
||||||
|
|
||||||
class Table(Renderable, RequiresNumbering):
|
class Table(Renderable, RequiresNumbering):
|
||||||
@@ -35,6 +36,7 @@ class Table(Renderable, RequiresNumbering):
|
|||||||
|
|
||||||
self._cell_margin_lr = left_margin + right_margin
|
self._cell_margin_lr = left_margin + right_margin
|
||||||
self._number = "?"
|
self._number = "?"
|
||||||
|
self.landscape = bool(caption_info and caption_info.landscape)
|
||||||
if caption_info and caption_info.unique_name:
|
if caption_info and caption_info.unique_name:
|
||||||
self.unique_name = 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:
|
def _make_continuation_paragraph(self) -> Paragraph:
|
||||||
continuation_paragraph = Paragraph(self._parent)
|
continuation_paragraph = Paragraph(self._parent)
|
||||||
continuation_paragraph.add_run(f"Продолжение Таблицы {self._number}")
|
continuation_paragraph.add_run(f"Продолжение Таблицы {self._number}")
|
||||||
continuation_paragraph.style = "Caption Table"
|
continuation_paragraph.style = "Название таблицы"
|
||||||
continuation_paragraph.first_line_indent = 0
|
continuation_paragraph.first_line_indent = 0
|
||||||
return continuation_paragraph
|
return continuation_paragraph
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,12 @@ class ToC(Renderable):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
display = title.strip()
|
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()
|
display = display.upper()
|
||||||
|
|
||||||
p.add_run(" " * (level - 1))
|
p.add_run(" " * (level - 1))
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ from .renderable.list import List
|
|||||||
from .renderable.toc import ToC
|
from .renderable.toc import ToC
|
||||||
from .renderable.diagram import DiagramFigure
|
from .renderable.diagram import DiagramFigure
|
||||||
from .label_pass import resolve_reference
|
from .label_pass import resolve_reference
|
||||||
from .diagram_renderer import DIAGRAM_LANGS
|
from .diagram_renderer import is_diagram_lang
|
||||||
|
|
||||||
|
|
||||||
class RenderableFactory:
|
class RenderableFactory:
|
||||||
def __init__(self, parent: Parented):
|
def __init__(self, parent: Parented, hr_pagebreak: bool = False):
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
|
self._hr_pagebreak = hr_pagebreak
|
||||||
|
|
||||||
@singledispatchmethod
|
@singledispatchmethod
|
||||||
def create(self, marko_element: extended_markdown.BlockElement,
|
def create(self, marko_element: extended_markdown.BlockElement,
|
||||||
@@ -43,7 +44,12 @@ class RenderableFactory:
|
|||||||
elif isinstance(child, extended_markdown.CodeSpan):
|
elif isinstance(child, extended_markdown.CodeSpan):
|
||||||
paragraph_or_link.add_run(child.children, is_italic=True)
|
paragraph_or_link.add_run(child.children, is_italic=True)
|
||||||
elif isinstance(child, extended_markdown.Image):
|
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)
|
paragraph_or_link.add_image(child.dest, caption)
|
||||||
elif isinstance(child, extended_markdown.LineBreak):
|
elif isinstance(child, extended_markdown.LineBreak):
|
||||||
pass
|
pass
|
||||||
@@ -82,7 +88,7 @@ class RenderableFactory:
|
|||||||
def _(self, marko_code_block: extended_markdown.FencedCode, caption_info: CaptionInfo):
|
def _(self, marko_code_block: extended_markdown.FencedCode, caption_info: CaptionInfo):
|
||||||
lang = (marko_code_block.lang or "").strip().lower()
|
lang = (marko_code_block.lang or "").strip().lower()
|
||||||
source = marko_code_block.children[0].children
|
source = marko_code_block.children[0].children
|
||||||
if lang in DIAGRAM_LANGS:
|
if is_diagram_lang(lang):
|
||||||
return DiagramFigure(
|
return DiagramFigure(
|
||||||
self._parent,
|
self._parent,
|
||||||
lang,
|
lang,
|
||||||
@@ -98,7 +104,7 @@ class RenderableFactory:
|
|||||||
def _(self, marko_code_block: extended_markdown.CodeBlock, caption_info: CaptionInfo):
|
def _(self, marko_code_block: extended_markdown.CodeBlock, caption_info: CaptionInfo):
|
||||||
lang = (getattr(marko_code_block, "lang", "") or "").strip().lower()
|
lang = (getattr(marko_code_block, "lang", "") or "").strip().lower()
|
||||||
source = marko_code_block.children[0].children
|
source = marko_code_block.children[0].children
|
||||||
if lang in DIAGRAM_LANGS:
|
if is_diagram_lang(lang):
|
||||||
return DiagramFigure(
|
return DiagramFigure(
|
||||||
self._parent,
|
self._parent,
|
||||||
lang,
|
lang,
|
||||||
@@ -161,3 +167,16 @@ class RenderableFactory:
|
|||||||
def _(self, marko_toc: extended_markdown.TOC, caption_info: CaptionInfo):
|
def _(self, marko_toc: extended_markdown.TOC, caption_info: CaptionInfo):
|
||||||
toc = ToC(self._parent)
|
toc = ToC(self._parent)
|
||||||
return toc
|
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
@@ -3,18 +3,24 @@ from itertools import chain
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from docx.document import Document
|
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.text import WD_PARAGRAPH_ALIGNMENT
|
||||||
|
from docx.enum.section import WD_SECTION
|
||||||
|
|
||||||
from .numberer import Numberer, APPENDIX_LETTERS
|
from .numberer import Numberer, APPENDIX_LETTERS
|
||||||
from .renderable import Renderable
|
from .renderable import Renderable
|
||||||
from .renderable.requires_numbering import RequiresNumbering
|
from .renderable.requires_numbering import RequiresNumbering
|
||||||
from .renderable.heading import Heading
|
from .renderable.heading import Heading
|
||||||
from .renderable.equation import Equation
|
from .renderable.equation import Equation
|
||||||
|
from .renderable.paragraph import Paragraph
|
||||||
from .rendered_info import RenderedInfo
|
from .rendered_info import RenderedInfo
|
||||||
from .sub_renderable import SubRenderable
|
from .sub_renderable import SubRenderable
|
||||||
from .util import create_element
|
|
||||||
from .layout_tracker import LayoutTracker
|
from .layout_tracker import LayoutTracker
|
||||||
|
from .page_geometry import (
|
||||||
|
apply_centered_page_footer,
|
||||||
|
apply_section_geometry,
|
||||||
|
content_size,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .debugger import Debugger
|
from .debugger import Debugger
|
||||||
@@ -22,7 +28,12 @@ if TYPE_CHECKING:
|
|||||||
BOTTOM_MARGIN = Mm(20)
|
BOTTOM_MARGIN = Mm(20)
|
||||||
|
|
||||||
APPENDIX_RE = re.compile(
|
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,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
SPECIAL_CENTER = {
|
SPECIAL_CENTER = {
|
||||||
@@ -48,13 +59,9 @@ class Renderer:
|
|||||||
self._appendix_index = 0
|
self._appendix_index = 0
|
||||||
self._after_toc = False
|
self._after_toc = False
|
||||||
self._body_section_started = False
|
self._body_section_started = False
|
||||||
|
self._landscape_depth = 0
|
||||||
|
|
||||||
max_height = (document.sections[0].page_height
|
max_height, max_width = content_size(landscape=False)
|
||||||
- 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)
|
|
||||||
self._layout_tracker = LayoutTracker(max_height, max_width)
|
self._layout_tracker = LayoutTracker(max_height, max_width)
|
||||||
|
|
||||||
# Front-matter section: no page numbers (титул / задание / содержание)
|
# Front-matter section: no page numbers (титул / задание / содержание)
|
||||||
@@ -62,6 +69,8 @@ class Renderer:
|
|||||||
|
|
||||||
self.previous_rendered = None
|
self.previous_rendered = None
|
||||||
self._to_new_page: list[Renderable] = []
|
self._to_new_page: list[Renderable] = []
|
||||||
|
self._after_current: list[Renderable] = []
|
||||||
|
self._need_space_after_object = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clear_footer(section):
|
def _clear_footer(section):
|
||||||
@@ -77,27 +86,39 @@ class Renderer:
|
|||||||
if self._body_section_started:
|
if self._body_section_started:
|
||||||
return
|
return
|
||||||
self._body_section_started = True
|
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 = self._document.add_section(WD_SECTION.NEW_PAGE)
|
||||||
new_section.page_width = self._document.sections[0].page_width
|
apply_section_geometry(new_section, landscape=False)
|
||||||
new_section.page_height = self._document.sections[0].page_height
|
apply_centered_page_footer(new_section)
|
||||||
new_section.left_margin = Mm(30)
|
|
||||||
new_section.right_margin = Mm(10)
|
|
||||||
new_section.top_margin = Mm(20)
|
|
||||||
new_section.bottom_margin = Mm(20)
|
|
||||||
|
|
||||||
paragraph = new_section.footer.paragraphs[0]
|
def _enter_landscape(self) -> None:
|
||||||
paragraph.paragraph_format.first_line_indent = 0
|
if self._landscape_depth > 0:
|
||||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
self._landscape_depth += 1
|
||||||
run = paragraph.add_run()
|
return
|
||||||
run.font.name = "Times New Roman"
|
self._flush_to_new_screen()
|
||||||
run.font.size = Pt(12)
|
if self._after_toc:
|
||||||
# PAGE field
|
self._ensure_body_section_with_page_numbers()
|
||||||
paragraph._p.append(create_element("w:fldSimple", {
|
elif not self._body_section_started:
|
||||||
"w:instr": "PAGE \\* MERGEFORMAT"
|
# 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]):
|
def process(self, renderables: list[Renderable]):
|
||||||
for i in range(len(renderables)):
|
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 document had no TOC, still add page numbers to the only section
|
||||||
if not self._body_section_started:
|
if not self._body_section_started:
|
||||||
section = self._document.sections[0]
|
section = self._document.sections[0]
|
||||||
paragraph = section.footer.paragraphs[0]
|
apply_centered_page_footer(section)
|
||||||
paragraph.paragraph_format.first_line_indent = 0
|
|
||||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
|
||||||
paragraph._p.append(create_element("w:fldSimple", {
|
|
||||||
"w:instr": "PAGE \\* MERGEFORMAT"
|
|
||||||
}))
|
|
||||||
if self._debugger:
|
if self._debugger:
|
||||||
self._debugger.after_rendered()
|
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):
|
def _handle_heading(self, heading: Heading):
|
||||||
text = (heading.text or "").strip()
|
text = (heading.text or "").strip()
|
||||||
upper = text.upper()
|
upper = text.upper().replace("Ё", "Е")
|
||||||
|
|
||||||
if heading.level == 1 and heading.is_numbered:
|
if heading.level == 1 and heading.is_numbered:
|
||||||
self._section_count += 1
|
self._section_count += 1
|
||||||
self._numberer.enter_section(self._section_count)
|
self._numberer.enter_section(self._section_count)
|
||||||
|
|
||||||
# Appendix: «Приложение А» / «ПРИЛОЖЕНИЕ А»
|
# Letter appendix item: «Приложение А» / «Приложение А Название»
|
||||||
m = APPENDIX_RE.match(upper.replace("Ё", "Е"))
|
# (not the section header «ПРИЛОЖЕНИЯ»)
|
||||||
if m or (heading.level <= 3 and upper.startswith("ПРИЛОЖЕНИЕ")):
|
item = APPENDIX_ITEM_RE.match(upper) if upper.startswith("ПРИЛОЖЕНИЕ ") else None
|
||||||
letter = None
|
if item:
|
||||||
if m:
|
letter = item.group(1).upper().replace("Ё", "Е")
|
||||||
letter = m.group(1).upper()
|
title_tail = (item.group(2) or "").strip()
|
||||||
else:
|
if letter in APPENDIX_LETTERS:
|
||||||
parts = upper.split()
|
self._numberer.enter_appendix(letter)
|
||||||
if len(parts) >= 2 and parts[1] in APPENDIX_LETTERS:
|
elif self._appendix_index < len(APPENDIX_LETTERS):
|
||||||
letter = parts[1]
|
letter = APPENDIX_LETTERS[self._appendix_index]
|
||||||
elif self._appendix_index < len(APPENDIX_LETTERS):
|
self._appendix_index += 1
|
||||||
letter = APPENDIX_LETTERS[self._appendix_index]
|
|
||||||
self._appendix_index += 1
|
|
||||||
if letter:
|
|
||||||
self._numberer.enter_appendix(letter)
|
self._numberer.enter_appendix(letter)
|
||||||
|
|
||||||
# Center special unnumbered headings that must be centered (already centered if unnumbered)
|
# Style as Heading 3, centered, new page (method guide §6)
|
||||||
if upper in SPECIAL_CENTER or upper.startswith("ПРИЛОЖЕНИЕ"):
|
heading.style = "Heading 3"
|
||||||
|
heading._level = 3
|
||||||
|
heading._remove_numbering()
|
||||||
heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.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)
|
||||||
|
heading.page_break_before = True
|
||||||
|
heading._docx_paragraph.paragraph_format.keep_with_next = True
|
||||||
|
|
||||||
# СОДЕРЖАНИЕ / СПИСОК — center even if somehow numbered
|
orig = (heading.text or "").strip()
|
||||||
if upper in ("СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"):
|
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.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||||
|
heading._docx_paragraph.paragraph_format.left_indent = Cm(0)
|
||||||
|
heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0)
|
||||||
|
|
||||||
# Native Word TOC picks up Heading 1–3; «СОДЕРЖАНИЕ» must not list itself
|
# Native Word TOC picks up Heading 1–3; «СОДЕРЖАНИЕ» must not list itself
|
||||||
if upper == "СОДЕРЖАНИЕ":
|
if upper == "СОДЕРЖАНИЕ":
|
||||||
@@ -159,69 +218,113 @@ class Renderer:
|
|||||||
# After ToC page-break renderable we open body section — detected via ToC's PageBreak
|
# After ToC page-break renderable we open body section — detected via ToC's PageBreak
|
||||||
from .renderable.toc import ToC
|
from .renderable.toc import ToC
|
||||||
from .renderable.page_break import PageBreak
|
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:
|
if isinstance(renderable, PageBreak) and self._after_toc:
|
||||||
self._ensure_body_section_with_page_numbers()
|
self._ensure_body_section_with_page_numbers()
|
||||||
|
|
||||||
if isinstance(renderable, ToC):
|
if isinstance(renderable, ToC):
|
||||||
self._after_toc = True
|
self._after_toc = True
|
||||||
|
|
||||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
# Space 6 mm before next body paragraph after table/listing
|
||||||
if isinstance(renderable, Equation):
|
if self._need_space_after_object:
|
||||||
label = renderable.unique_name
|
self._need_space_after_object = False
|
||||||
if renderable.needs_numbering or (label and label in self._numbered_equations):
|
if isinstance(renderable, (Paragraph, RList)) and not isinstance(renderable, Heading):
|
||||||
renderable.enable_numbering()
|
if isinstance(renderable, RList):
|
||||||
number = self._numberer.next_number(
|
# Apply to first list item when rendered — mark on list
|
||||||
renderable.numbering_category, label)
|
setattr(renderable, "_space_before_mm6", True)
|
||||||
renderable.set_number(number)
|
else:
|
||||||
else:
|
renderable._docx_paragraph.paragraph_format.space_before = Mm(6)
|
||||||
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)
|
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:
|
try:
|
||||||
first = next(infos)
|
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
||||||
if isinstance(first, RenderedInfo) and first.height \
|
if isinstance(renderable, Equation):
|
||||||
>= self._layout_tracker.current_state.remaining_page_height:
|
label = renderable.unique_name
|
||||||
self._flush_to_new_screen()
|
if renderable.needs_numbering or (label and label in self._numbered_equations):
|
||||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
renderable.enable_numbering()
|
||||||
else:
|
number = self._numberer.next_number(
|
||||||
infos = chain([first], infos)
|
renderable.numbering_category, label)
|
||||||
except StopIteration:
|
renderable.set_number(number)
|
||||||
pass
|
|
||||||
|
|
||||||
for info in infos:
|
|
||||||
if isinstance(info, SubRenderable):
|
|
||||||
if info.add_to_new_page:
|
|
||||||
self._to_new_page.append(info.renderable)
|
|
||||||
else:
|
else:
|
||||||
self.render(info.renderable)
|
number = self._numberer.next_number(
|
||||||
else:
|
renderable.numbering_category,
|
||||||
self._add(info.docx_element, info.height)
|
getattr(renderable, "unique_name", None),
|
||||||
self.previous_rendered = info
|
)
|
||||||
|
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):
|
def _flush_to_new_screen(self):
|
||||||
while self._to_new_page:
|
while self._to_new_page:
|
||||||
renderable = self._to_new_page.pop(0)
|
renderable = self._to_new_page.pop(0)
|
||||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
self.render(renderable)
|
||||||
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_
|
|
||||||
|
|
||||||
def _add(self, element: Parented, height: Length):
|
def _add(self, element: Parented, height: Length):
|
||||||
self._document._body._element.append(
|
# MUST insert before the body-level w:sectPr. append() puts content after it,
|
||||||
element._element
|
# 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)
|
self._layout_tracker.add_height(height)
|
||||||
|
|
||||||
if self._debugger:
|
if self._debugger:
|
||||||
|
|||||||
+40
-8
@@ -173,13 +173,11 @@ def apply_document_styles(document: Document, style_preset: str = "mirea") -> No
|
|||||||
|
|
||||||
|
|
||||||
def _apply_common_page_and_body(document: Document) -> None:
|
def _apply_common_page_and_body(document: Document) -> None:
|
||||||
|
from .page_geometry import apply_section_geometry, is_landscape_section
|
||||||
|
|
||||||
for section in document.sections:
|
for section in document.sections:
|
||||||
section.page_width = Mm(210)
|
# Do not wipe landscape sections created for +landscape figures/tables.
|
||||||
section.page_height = Mm(297)
|
apply_section_geometry(section, landscape=is_landscape_section(section))
|
||||||
section.left_margin = Mm(30)
|
|
||||||
section.right_margin = Mm(10)
|
|
||||||
section.top_margin = Mm(20)
|
|
||||||
section.bottom_margin = Mm(20)
|
|
||||||
|
|
||||||
_fix_toc_tab_stops(document)
|
_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.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||||
cpf.widow_control = True
|
cpf.widow_control = True
|
||||||
|
|
||||||
# --- Caption Table: 12pt italic, left, above table ---
|
# --- Caption Table (legacy EN name) + «Название таблицы» (основной) ---
|
||||||
caption_tbl = _ensure_style(document, "Caption Table", "Caption")
|
caption_tbl = _ensure_style(document, "Caption Table", "Caption")
|
||||||
_set_run_font(caption_tbl, "Times New Roman", 12, italic=True)
|
_set_run_font(caption_tbl, "Times New Roman", 12, italic=True)
|
||||||
tpf = caption_tbl.paragraph_format
|
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_before = Mm(6)
|
||||||
tpf.space_after = Mm(0)
|
tpf.space_after = Mm(0)
|
||||||
tpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
tpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||||
|
tpf.keep_with_next = True
|
||||||
tpf.widow_control = 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 Listing (как таблицы) ---
|
||||||
caption_lst = _ensure_style(document, "Caption Listing", "Caption")
|
caption_lst = _ensure_style(document, "Caption Listing", "Caption")
|
||||||
_set_run_font(caption_lst, "Times New Roman", 12, italic=True)
|
_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")
|
table_text = _ensure_style(document, "Table Text", "Normal")
|
||||||
_set_run_font(table_text, "Times New Roman", 12)
|
_set_run_font(table_text, "Times New Roman", 12)
|
||||||
ttf = table_text.paragraph_format
|
ttf = table_text.paragraph_format
|
||||||
|
ttf.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||||
ttf.first_line_indent = Cm(0)
|
ttf.first_line_indent = Cm(0)
|
||||||
|
ttf.left_indent = Cm(0)
|
||||||
ttf.space_before = Mm(0)
|
ttf.space_before = Mm(0)
|
||||||
ttf.space_after = Mm(0)
|
ttf.space_after = Mm(0)
|
||||||
ttf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
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 = biblio_h.paragraph_format
|
||||||
bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
bhpf.first_line_indent = Cm(0)
|
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_before = Mm(6)
|
||||||
bhpf.space_after = Mm(6)
|
bhpf.space_after = Mm(6)
|
||||||
bhpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
bhpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||||
bhpf.keep_with_next = True
|
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:
|
try:
|
||||||
footer_style = document.styles["Footer"]
|
footer_style = document.styles["Footer"]
|
||||||
_set_run_font(footer_style, "Times New Roman", 12)
|
_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")
|
after = _ensure_style(document, "Space After Table", "Normal")
|
||||||
apf = after.paragraph_format
|
apf = after.paragraph_format
|
||||||
apf.space_before = Mm(6)
|
apf.space_before = Mm(6)
|
||||||
|
apf.space_after = Pt(0)
|
||||||
apf.first_line_indent = Cm(1.25)
|
apf.first_line_indent = Cm(1.25)
|
||||||
|
apf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||||
|
|||||||
Vendored
@@ -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
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
Правила заголовков:
|
Правила заголовков:
|
||||||
|
|
||||||
- Спецразделы **без номера**: `# *СОДЕРЖАНИЕ`, `# *ВВЕДЕНИЕ`, `# *ЗАКЛЮЧЕНИЕ`, `# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ`, `# *ПРИЛОЖЕНИЯ` — ПРОПИСНЫМИ.
|
- Спецразделы **без номера**: `# *СОДЕРЖАНИЕ`, `# *ВВЕДЕНИЕ`, `# *ЗАКЛЮЧЕНИЕ`, `# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ`, `# *ПРИЛОЖЕНИЯ` — ПРОПИСНЫМИ.
|
||||||
|
- По центру в DOCX будут только «СОДЕРЖАНИЕ» и «СПИСОК…»; Введение / Заключение / ПРИЛОЖЕНИЯ — слева с отступом 1,25 см.
|
||||||
- Нумерованные разделы основной части: `# 1 …`, `## 1.1 …` (или без цифр в тексте — нумерацию даст конвертер/Word; предпочтительно осмысленные названия без точки в конце).
|
- Нумерованные разделы основной части: `# 1 …`, `## 1.1 …` (или без цифр в тексте — нумерацию даст конвертер/Word; предпочтительно осмысленные названия без точки в конце).
|
||||||
- В конце названия заголовка точка не ставится.
|
- В конце названия заголовка точка не ставится.
|
||||||
|
|
||||||
@@ -99,9 +100,9 @@
|
|||||||
| ^ | Требование 2 |
|
| ^ | Требование 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
|
~~~markdown
|
||||||
См. @Рисунок:usecase1 и @Листинг:usecase1.
|
См. @Рисунок:usecase1 и @Листинг:usecase1.
|
||||||
@@ -114,6 +115,28 @@ actor Student
|
|||||||
Student --> (Login)
|
Student --> (Login)
|
||||||
@enduml
|
@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
|
```markdown
|
||||||
# *ПРИЛОЖЕНИЯ
|
# *ПРИЛОЖЕНИЯ
|
||||||
|
|
||||||
## Приложение А Название
|
Приложение А — Листинг модуля
|
||||||
|
Приложение Б — Графический материал
|
||||||
|
|
||||||
|
## Приложение А Листинг модуля
|
||||||
|
|
||||||
|
…
|
||||||
|
|
||||||
|
## Приложение Б Графический материал
|
||||||
|
|
||||||
|
…
|
||||||
```
|
```
|
||||||
|
|
||||||
Буквы: А, Б, В, Г, Д, Е, Ж, И, К… **Нельзя:** Ё, З, Й, О, Ч, Ь, Ы, Ъ.
|
- Раздел `# *ПРИЛОЖЕНИЯ` — ПРОПИСНЫМИ, **слева** (как H1), не по центру.
|
||||||
|
- При нескольких приложениях сразу после заголовка — **перечень** основным текстом.
|
||||||
|
- Каждое `## Приложение А Название` конвертер оформит как «Приложение А» (H3, по центру) + название следующей строкой.
|
||||||
|
- Буквы: А, Б, В, Г, Д, Е, Ж, И, К… **Нельзя:** Ё, З, Й, О, Ч, Ь, Ы, Ъ.
|
||||||
|
|
||||||
## Оформление текста (смысловые правила markdown)
|
## Оформление текста (смысловые правила markdown)
|
||||||
|
|
||||||
- Кавычки русские: «…».
|
- Кавычки русские: «…».
|
||||||
- Тире в предложениях: «—» (с пробелами). Дефис в сложных словах и диапазонах: «-» без пробелов (100-150).
|
- Тире в предложениях: «—» (с пробелами). Дефис в сложных словах и диапазонах: «-» без пробелов (100-150). **Не заменяйте «—» на «-»** (в md2gost по умолчанию тире сохраняется).
|
||||||
- Пробел после знака препинания, не перед ним; без пробела после «» и перед «».
|
- Пробел после знака препинания, не перед ним; без пробела после «» и перед «».
|
||||||
- Маркированный список: единообразный маркер; пункты со **строчной** буквы, в конце `;`, у последнего `.`.
|
- Маркированный список: единообразный маркер; пункты со **строчной** буквы, в конце `;`, у последнего `.`.
|
||||||
- Нумерованный список: с **прописной**, в конце `.`.
|
- Нумерованный список: с **прописной**, в конце `.`.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
Листинг: `%id Название` + fenced code.
|
Листинг: `%id Название` + fenced code.
|
||||||
|
|
||||||
Диаграмма (Рисунок из PlantUML/BPMN/C4):
|
Диаграмма (Рисунок из PlantUML / Mermaid / схем):
|
||||||
|
|
||||||
```
|
```
|
||||||
%usecase1 Диаграмма прецедентов +listing
|
%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`.
|
Ссылки: `@Рисунок:usecase1`, `@Листинг:usecase1`.
|
||||||
|
|
||||||
Формула (обычно работа №8): `$$ … $$` с `%eq_id` и ссылкой `@Формула:eq_id`.
|
Формула (обычно работа №8): `$$ … $$` с `%eq_id` и ссылкой `@Формула:eq_id`.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
md2gost = "md2gost.__main__:main"
|
md2gost = "md2gost.__main__:main"
|
||||||
|
md2gost-gui = "md2gost.gui:main"
|
||||||
md2latex = "md2latex.__main__:main"
|
md2latex = "md2latex.__main__:main"
|
||||||
md2fodt = "md2fodt.__main__:main"
|
md2fodt = "md2fodt.__main__:main"
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ dev = [
|
|||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
word = [
|
||||||
|
"pywin32>=306; sys_platform == 'win32'",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
@@ -38,6 +44,8 @@ packages = ["md2gost", "md2latex", "md2fodt"]
|
|||||||
"md2gost/mml2omml" = "md2gost/mml2omml"
|
"md2gost/mml2omml" = "md2gost/mml2omml"
|
||||||
"md2gost/diagrams" = "md2gost/diagrams"
|
"md2gost/diagrams" = "md2gost/diagrams"
|
||||||
"latex/mirea" = "latex/mirea"
|
"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)
|
# Legacy Poetry metadata (optional; prefer uv + [project] above)
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
@@ -61,3 +69,4 @@ pygments = "^2.16.1"
|
|||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
md2gost = "md2gost.__main__:main"
|
md2gost = "md2gost.__main__:main"
|
||||||
|
md2gost-gui = "md2gost.gui:main"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -1,16 +1,35 @@
|
|||||||
"""Tests for diagram renderer and factory routing."""
|
"""Tests for diagram renderer, schemes, and include cache."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
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 (
|
from md2gost.diagram_renderer import (
|
||||||
DIAGRAM_LANGS,
|
DIAGRAM_LANGS,
|
||||||
|
configure_diagrams,
|
||||||
|
diagram_engine_status,
|
||||||
|
is_diagram_lang,
|
||||||
prepare_source,
|
prepare_source,
|
||||||
render_diagram,
|
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 import markdown
|
||||||
from md2gost.extended_markdown.caption import Caption as CapEl
|
from md2gost.extended_markdown.caption import Caption as CapEl
|
||||||
@@ -26,16 +45,148 @@ def test_prepare_uml_wraps_startuml():
|
|||||||
assert dtype == "plantuml"
|
assert dtype == "plantuml"
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_c4_adds_include():
|
def test_prepare_c4_uses_scheme(tmp_path, monkeypatch):
|
||||||
src, dtype = prepare_source("c4", "Person(user, \"User\")")
|
monkeypatch.chdir(tmp_path)
|
||||||
assert "!include" in src
|
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 dtype == "plantuml"
|
||||||
|
assert "Person(user" in src
|
||||||
|
assert "@startuml" in src.lower()
|
||||||
|
assert "!include" in src
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_bpmn():
|
def test_prepare_uml_c4_alias(tmp_path, monkeypatch):
|
||||||
src, dtype = prepare_source("bpmn", "start -> end")
|
monkeypatch.chdir(tmp_path)
|
||||||
assert dtype == "bpmn"
|
ensure_user_schemes(tmp_path)
|
||||||
assert "@startbpmn" in src.lower() or "start" in src
|
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():
|
def test_caption_plus_listing():
|
||||||
@@ -47,6 +198,45 @@ def test_caption_plus_listing():
|
|||||||
assert caps[0].unique_name == "fig"
|
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():
|
def test_factory_routes_uml_to_diagram():
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from md2gost import package_dir
|
from md2gost import package_dir
|
||||||
@@ -77,8 +267,8 @@ def test_factory_routes_uml_to_diagram():
|
|||||||
def test_render_diagram_uses_cache(tmp_path):
|
def test_render_diagram_uses_cache(tmp_path):
|
||||||
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
|
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
|
||||||
|
|
||||||
def fake_kroki(source, diagram_type, base_url, out_png):
|
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
|
||||||
out_png.write_bytes(png)
|
out_path.write_bytes(png if fmt == "png" else b"<svg xmlns='http://www.w3.org/2000/svg'/>")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
|
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):
|
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
|
||||||
path1 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
|
path1 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
|
||||||
path2 = 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 path1.png_path == path2.png_path
|
||||||
assert open(path1, "rb").read().startswith(b"\x89PNG")
|
assert open(path1.png_path, "rb").read().startswith(b"\x89PNG")
|
||||||
|
|
||||||
|
|
||||||
def test_render_diagram_off_raises(tmp_path):
|
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))
|
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():
|
def test_diagram_langs():
|
||||||
assert "uml" in DIAGRAM_LANGS
|
assert "uml" in DIAGRAM_LANGS
|
||||||
assert "c4" in DIAGRAM_LANGS
|
assert "c4" in DIAGRAM_LANGS
|
||||||
assert "bpmn" 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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -378,3 +378,164 @@ def test_apid_biblio_min_seven():
|
|||||||
assert any(i.id == "biblio.count" for i in issues)
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -157,14 +157,14 @@ def test_checker_merge_ok():
|
|||||||
|
|
||||||
def test_table_continuation_modes():
|
def test_table_continuation_modes():
|
||||||
from md2gost.profiles import TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION
|
from md2gost.profiles import TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION
|
||||||
assert DEFAULT_TABLE_CONTINUATION == "off"
|
assert DEFAULT_TABLE_CONTINUATION == "word"
|
||||||
assert TABLE_CONTINUATION_MODES == ("off", "legacy", "caption", "soft")
|
assert TABLE_CONTINUATION_MODES == ("off", "legacy", "caption", "soft", "word")
|
||||||
|
|
||||||
doc = Document(os.path.join(package_dir(), "Template.docx"))
|
doc = Document(os.path.join(package_dir(), "Template.docx"))
|
||||||
from md2gost.renderable.table import Table as RTable
|
from md2gost.renderable.table import Table as RTable
|
||||||
from md2gost.renderable.caption import CaptionInfo
|
from md2gost.renderable.caption import CaptionInfo
|
||||||
t = RTable(doc._body, 1, 2, CaptionInfo("x", "y"))
|
t = RTable(doc._body, 1, 2, CaptionInfo("x", "y"))
|
||||||
assert t._continuation_mode == "off"
|
assert t._continuation_mode == "word"
|
||||||
t.set_continuation_mode("legacy")
|
t.set_continuation_mode("legacy")
|
||||||
assert t._continuation_mode == "legacy"
|
assert t._continuation_mode == "legacy"
|
||||||
t.set_continuation_mode("caption")
|
t.set_continuation_mode("caption")
|
||||||
|
|||||||
@@ -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.
@@ -0,0 +1,13 @@
|
|||||||
|
# *ВВЕДЕНИЕ
|
||||||
|
|
||||||
|
Текст.
|
||||||
|
|
||||||
|
%code1 Пример
|
||||||
|
|
||||||
|
```bash
|
||||||
|
line1
|
||||||
|
line2
|
||||||
|
line3
|
||||||
|
line4
|
||||||
|
line5
|
||||||
|
```
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user