v0.5.6
This commit is contained in:
+1
-1
@@ -51,7 +51,7 @@ if exist "%USERPROFILE%\.cargo\bin\uvx.exe" set "UVX_BIN=%USERPROFILE%\.cargo\bi
|
|||||||
if defined UVX_BIN goto :got_uvx
|
if defined UVX_BIN goto :got_uvx
|
||||||
|
|
||||||
echo uvx не найден. Установите uv: https://docs.astral.sh/uv/getting-started/installation/
|
echo uvx не найден. Установите uv: https://docs.astral.sh/uv/getting-started/installation/
|
||||||
echo powershell: irm https://astral.sh/uv/install.ps1 ^| iex
|
echo powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||||
goto :fail
|
goto :fail
|
||||||
|
|
||||||
:got_uvx
|
:got_uvx
|
||||||
|
|||||||
+2
-1
@@ -34,6 +34,7 @@ python -m word2md report.docx -o report.md
|
|||||||
| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) |
|
| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) |
|
||||||
| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` |
|
| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` |
|
||||||
| `--listing-continuation` | Основные | то же для листингов |
|
| `--listing-continuation` | Основные | то же для листингов |
|
||||||
|
| `--office-engine` | Основные → Движок пост-разрыва | `auto` (Word → LibreOffice → OpenOffice), `word`, `libre`, `openoffice` |
|
||||||
| `--table-repeat-header` | Галочка | шапка на фрагментах после word-split |
|
| `--table-repeat-header` | Галочка | шапка на фрагментах после word-split |
|
||||||
| `--plantuml-jar` / `--kroki-url` | Настройки → Диаграммы | локальный рендер UML / запасной Kroki |
|
| `--plantuml-jar` / `--kroki-url` | Настройки → Диаграммы | локальный рендер UML / запасной Kroki |
|
||||||
| `--diagram-fallback` | Диаграммы | `remote` / `local` / `off` (отключает только kroki.io) |
|
| `--diagram-fallback` | Диаграммы | `remote` / `local` / `off` (отключает только kroki.io) |
|
||||||
@@ -49,7 +50,7 @@ python -m word2md report.docx -o report.md
|
|||||||
|
|
||||||
## Продолжение таблиц и листингов
|
## Продолжение таблиц и листингов
|
||||||
|
|
||||||
DOCX не умеет сам писать «Продолжение Таблицы N» только со 2-й страницы. Режим **`word`** (по умолчанию): после сохранения Word COM режет по реальной пагинации. Нужны Windows + Word + pywin32. Без Word: `--table-continuation off` (и то же для листингов).
|
DOCX не умеет сам писать «Продолжение Таблицы N» только со 2-й страницы. Режим **`word`** (по умолчанию): после сохранения режет по реальной пагинации выбранным движком (`--office-engine`): Microsoft Word COM (Windows + pywin32) или LibreOffice/OpenOffice Writer через UNO (`soffice` + `program/python`). По умолчанию `auto` пробует Word, затем LibreOffice, затем OpenOffice. Без офиса: `--table-continuation off` (и то же для листингов).
|
||||||
|
|
||||||
## Меню GUI
|
## Меню GUI
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ datas = [
|
|||||||
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
||||||
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
||||||
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
||||||
|
(str(ROOT / "md2gost" / "office" / "uno_worker.py"), "md2gost/office"),
|
||||||
|
(str(ROOT / "md2gost" / "office" / "uno_helpers.py"), "md2gost/office"),
|
||||||
(str(ROOT / "prompts"), "prompts"),
|
(str(ROOT / "prompts"), "prompts"),
|
||||||
(str(ROOT / "docs"), "docs"),
|
(str(ROOT / "docs"), "docs"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ datas = [
|
|||||||
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
||||||
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
||||||
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
||||||
|
(str(ROOT / "md2gost" / "office" / "uno_worker.py"), "md2gost/office"),
|
||||||
|
(str(ROOT / "md2gost" / "office" / "uno_helpers.py"), "md2gost/office"),
|
||||||
(str(ROOT / "prompts"), "prompts"),
|
(str(ROOT / "prompts"), "prompts"),
|
||||||
(str(ROOT / "docs"), "docs"),
|
(str(ROOT / "docs"), "docs"),
|
||||||
]
|
]
|
||||||
|
|||||||
+8
-3
@@ -132,28 +132,33 @@ python -m md2gost report.md -o report.docx --toc manual
|
|||||||
|
|
||||||
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
|
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
|
||||||
mid-page «Продолжение…» (как было на 2.4). По умолчанию режим **`word`**: после save
|
mid-page «Продолжение…» (как было на 2.4). По умолчанию режим **`word`**: после save
|
||||||
Word COM режет по реальной пагинации. Без Word — укажите `off` или поставьте Word + pywin32.
|
живой движок вёрстки режет по реальной пагинации (`--office-engine`, по умолчанию `auto`:
|
||||||
|
Word COM → LibreOffice UNO → OpenOffice UNO). Без офиса — `off`.
|
||||||
|
|
||||||
| Режим | Поведение |
|
| Режим | Поведение |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). Таблицы с вертикальным merge (`^`): Word не умеет `Split` при vMerge — merge временно снимается, после разреза восстанавливается в обоих фрагментах |
|
| **`word`** (по умолчанию) | Как `off` при рендере; после save: `Split` + «Продолжение Таблицы N» через Word COM или LibreOffice/OpenOffice UNO (`--office-engine`). Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). В Word при vMerge (`^`): merge временно снимается, после разреза восстанавливается в обоих фрагментах |
|
||||||
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
|
| **`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 word
|
||||||
|
python -m md2gost report.md -o report.docx --table-continuation word --office-engine libre
|
||||||
|
python -m md2gost report.md -o report.docx --table-continuation word --office-engine openoffice
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
OpenOffice/LibreOffice UNO используют постоянный профиль `%LOCALAPPDATA%\md2gost\office-profiles\` (без мастера регистрации на каждый запуск). Если OpenOffice всё же просит регистрацию один раз — завершите её; дальше профиль сохранится.
|
||||||
|
|
||||||
### Продолжение листинга (`--listing-continuation`)
|
### Продолжение листинга (`--listing-continuation`)
|
||||||
|
|
||||||
Те же режимы, что у таблиц. По умолчанию **`word`**.
|
Те же режимы, что у таблиц. По умолчанию **`word`**.
|
||||||
|
|
||||||
| Режим | Поведение |
|
| Режим | Поведение |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| **`word`** (по умолчанию) | После save Word COM + «Продолжение Листинга N» (Windows + Word + pywin32) |
|
| **`word`** (по умолчанию) | После save + «Продолжение Листинга N» (Word COM или LibreOffice/OpenOffice UNO) |
|
||||||
| **`off`** / **`soft`** | Один блок кода; пагинацию делает Word. «Продолжение…» — вручную в markdown, если нужно |
|
| **`off`** / **`soft`** | Один блок кода; пагинацию делает Word. «Продолжение…» — вручную в markdown, если нужно |
|
||||||
| **`legacy`** | Режем по оценке высоты + «Продолжение Листинга N» с `page_break_before` |
|
| **`legacy`** | Режем по оценке высоты + «Продолжение Листинга N» с `page_break_before` |
|
||||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение Листинга N» |
|
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение Листинга N» |
|
||||||
|
|||||||
+18
-4
@@ -4,6 +4,7 @@ import sys
|
|||||||
|
|
||||||
from .doc_metadata import AUTHOR_SOURCES, DEFAULT_DOC_COMMENTS
|
from .doc_metadata import AUTHOR_SOURCES, DEFAULT_DOC_COMMENTS
|
||||||
from .pipeline import ConvertRequest, convert, should_launch_gui
|
from .pipeline import ConvertRequest, convert, should_launch_gui
|
||||||
|
from .office import DEFAULT_OFFICE_ENGINE, OFFICE_ENGINE_MODES
|
||||||
from .profiles import (
|
from .profiles import (
|
||||||
DEFAULT_HEADING_NUMBERING,
|
DEFAULT_HEADING_NUMBERING,
|
||||||
DEFAULT_TABLE_CONTINUATION,
|
DEFAULT_TABLE_CONTINUATION,
|
||||||
@@ -69,8 +70,8 @@ def build_parser() -> ArgumentParser:
|
|||||||
default=DEFAULT_TABLE_CONTINUATION,
|
default=DEFAULT_TABLE_CONTINUATION,
|
||||||
help=(
|
help=(
|
||||||
"Таблицы длиннее страницы: "
|
"Таблицы длиннее страницы: "
|
||||||
"word — после сохранения Word COM режет по реальной пагинации + «Продолжение…» "
|
"word — после сохранения режет по реальной пагинации + «Продолжение…» "
|
||||||
"(по умолчанию; нужны Windows, Word, pywin32); "
|
"(по умолчанию; движок: --office-engine auto|word|libre|openoffice); "
|
||||||
"off/soft — одна таблица, пагинация Word, без авто«Продолжение»; "
|
"off/soft — одна таблица, пагинация Word, без авто«Продолжение»; "
|
||||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
||||||
"(оценка ≠ Word, возможны артефакты). "
|
"(оценка ≠ Word, возможны артефакты). "
|
||||||
@@ -83,8 +84,8 @@ def build_parser() -> ArgumentParser:
|
|||||||
default=DEFAULT_LISTING_CONTINUATION,
|
default=DEFAULT_LISTING_CONTINUATION,
|
||||||
help=(
|
help=(
|
||||||
"Листинги длиннее страницы: "
|
"Листинги длиннее страницы: "
|
||||||
"word — после сохранения Word COM + «Продолжение Листинга…» "
|
"word — после сохранения + «Продолжение Листинга…» "
|
||||||
"(по умолчанию; нужны Windows, Word, pywin32); "
|
"(по умолчанию; движок: --office-engine auto|word|libre|openoffice); "
|
||||||
"off/soft — один блок, пагинация Word, без авто«Продолжение»; "
|
"off/soft — один блок, пагинация Word, без авто«Продолжение»; "
|
||||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение Листинга…» "
|
"legacy/caption — режем по оценке высоты и вставляем «Продолжение Листинга…» "
|
||||||
"(оценка ≠ Word, возможны артефакты). "
|
"(оценка ≠ Word, возможны артефакты). "
|
||||||
@@ -100,6 +101,18 @@ def build_parser() -> ArgumentParser:
|
|||||||
"после разрыва. По умолчанию выкл."
|
"после разрыва. По умолчанию выкл."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--office-engine",
|
||||||
|
choices=OFFICE_ENGINE_MODES,
|
||||||
|
default=DEFAULT_OFFICE_ENGINE,
|
||||||
|
help=(
|
||||||
|
"Движок пост-разрыва таблиц/листингов при --table-continuation word: "
|
||||||
|
"auto — Word, иначе LibreOffice, иначе OpenOffice; "
|
||||||
|
"word — только Microsoft Word COM (Windows + pywin32); "
|
||||||
|
"libre / openoffice — UNO Writer (soffice + program/python). "
|
||||||
|
f"По умолчанию: {DEFAULT_OFFICE_ENGINE}."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--emdash-to-hyphen",
|
"--emdash-to-hyphen",
|
||||||
action=BooleanOptionalAction,
|
action=BooleanOptionalAction,
|
||||||
@@ -280,6 +293,7 @@ def request_from_args(args) -> ConvertRequest:
|
|||||||
table_continuation=args.table_continuation,
|
table_continuation=args.table_continuation,
|
||||||
listing_continuation=args.listing_continuation,
|
listing_continuation=args.listing_continuation,
|
||||||
table_repeat_header=bool(getattr(args, "table_repeat_header", False)),
|
table_repeat_header=bool(getattr(args, "table_repeat_header", False)),
|
||||||
|
office_engine=getattr(args, "office_engine", DEFAULT_OFFICE_ENGINE),
|
||||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
emdash_to_hyphen=args.emdash_to_hyphen,
|
||||||
hr_pagebreak=args.hr_pagebreak,
|
hr_pagebreak=args.hr_pagebreak,
|
||||||
title=args.title,
|
title=args.title,
|
||||||
|
|||||||
@@ -423,8 +423,8 @@ def check_continuation_hints(
|
|||||||
issues.append(Issue(
|
issues.append(Issue(
|
||||||
"table.continuation", "warning",
|
"table.continuation", "warning",
|
||||||
"Word сам не вставит «Продолжение Таблицы N» при переносе. "
|
"Word сам не вставит «Продолжение Таблицы N» при переносе. "
|
||||||
"Варианты: --table-continuation word (точный разрыв через Word COM) "
|
"Варианты: --table-continuation word (Word COM или LibreOffice/OpenOffice UNO; "
|
||||||
"или caption (оценка высоты ≠ вёрстка Word, возможны артефакты).",
|
"см. --office-engine) или caption (оценка высоты ≠ вёрстка, возможны артефакты).",
|
||||||
))
|
))
|
||||||
# Long fenced code blocks (likely listings)
|
# Long fenced code blocks (likely listings)
|
||||||
long_listing = False
|
long_listing = False
|
||||||
|
|||||||
+114
-9
@@ -29,6 +29,7 @@ from .help_content import (
|
|||||||
load_docs_catalog,
|
load_docs_catalog,
|
||||||
load_prompt_catalog,
|
load_prompt_catalog,
|
||||||
)
|
)
|
||||||
|
from .office import DEFAULT_OFFICE_ENGINE, OFFICE_ENGINE_MODES
|
||||||
from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path
|
from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path
|
||||||
from .profiles import (
|
from .profiles import (
|
||||||
DEFAULT_HEADING_NUMBERING,
|
DEFAULT_HEADING_NUMBERING,
|
||||||
@@ -64,6 +65,14 @@ TABLE_LABELS = {
|
|||||||
"legacy": "Резать + «Продолжение»",
|
"legacy": "Резать + «Продолжение»",
|
||||||
"caption": "Резать + разрыв страницы + «Продолжение»",
|
"caption": "Резать + разрыв страницы + «Продолжение»",
|
||||||
}
|
}
|
||||||
|
OFFICE_ENGINE_LABELS = {
|
||||||
|
"auto": "Авто: Word → LibreOffice",
|
||||||
|
"word": "Только Microsoft Word",
|
||||||
|
"libre": "LibreOffice Writer (UNO)",
|
||||||
|
}
|
||||||
|
GUI_OFFICE_ENGINE_MODES = tuple(
|
||||||
|
key for key in OFFICE_ENGINE_MODES if key != "openoffice"
|
||||||
|
)
|
||||||
FALLBACK_LABELS = {
|
FALLBACK_LABELS = {
|
||||||
"remote": "Авто: локально, иначе интернет (kroki.io)",
|
"remote": "Авто: локально, иначе интернет (kroki.io)",
|
||||||
"local": "Только этот компьютер (без kroki.io)",
|
"local": "Только этот компьютер (без kroki.io)",
|
||||||
@@ -245,6 +254,7 @@ class Md2GostApp:
|
|||||||
self.toc_var = tk.StringVar()
|
self.toc_var = tk.StringVar()
|
||||||
self.table_var = tk.StringVar()
|
self.table_var = tk.StringVar()
|
||||||
self.listing_var = tk.StringVar()
|
self.listing_var = tk.StringVar()
|
||||||
|
self.office_engine_var = tk.StringVar()
|
||||||
self.emdash_var = tk.BooleanVar(value=False)
|
self.emdash_var = tk.BooleanVar(value=False)
|
||||||
self.hr_pagebreak_var = tk.BooleanVar(value=False)
|
self.hr_pagebreak_var = tk.BooleanVar(value=False)
|
||||||
self.auto_title_var = tk.BooleanVar(value=False)
|
self.auto_title_var = tk.BooleanVar(value=False)
|
||||||
@@ -1067,10 +1077,18 @@ class Md2GostApp:
|
|||||||
tip="off — Word сам переносит; word — Split + «Продолжение» после сборки (нужен Word)")
|
tip="off — Word сам переносит; word — Split + «Продолжение» после сборки (нужен Word)")
|
||||||
self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES),
|
self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES),
|
||||||
tip="То же для листингов; word требует Windows + Word + pywin32")
|
tip="То же для листингов; word требует Windows + Word + pywin32")
|
||||||
|
self._combo(
|
||||||
|
main,
|
||||||
|
5,
|
||||||
|
"Движок пост-разрыва",
|
||||||
|
self.office_engine_var,
|
||||||
|
_combo_values(OFFICE_ENGINE_LABELS, GUI_OFFICE_ENGINE_MODES),
|
||||||
|
tip="При режиме word: Word COM или LibreOffice UNO (soffice)",
|
||||||
|
)
|
||||||
|
|
||||||
ttk.Label(main, text="Смещение страниц").grid(row=5, column=0, sticky="w", padx=(0, 8), pady=3)
|
ttk.Label(main, text="Смещение страниц").grid(row=6, column=0, sticky="w", padx=(0, 8), pady=3)
|
||||||
page_row = ttk.Frame(main)
|
page_row = ttk.Frame(main)
|
||||||
page_row.grid(row=5, column=1, sticky="w", pady=3)
|
page_row.grid(row=6, column=1, sticky="w", pady=3)
|
||||||
page_entry = ttk.Entry(page_row, textvariable=self.page_start_var, width=8)
|
page_entry = ttk.Entry(page_row, textvariable=self.page_start_var, width=8)
|
||||||
page_entry.pack(side=tk.LEFT)
|
page_entry.pack(side=tk.LEFT)
|
||||||
_tip(
|
_tip(
|
||||||
@@ -1085,7 +1103,7 @@ class Md2GostApp:
|
|||||||
).pack(side=tk.LEFT)
|
).pack(side=tk.LEFT)
|
||||||
|
|
||||||
flags = ttk.Frame(main)
|
flags = ttk.Frame(main)
|
||||||
flags.grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
flags.grid(row=7, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||||
checks = [
|
checks = [
|
||||||
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
|
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
|
||||||
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
|
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
|
||||||
@@ -1139,17 +1157,41 @@ class Md2GostApp:
|
|||||||
|
|
||||||
log_frame = ttk.LabelFrame(outer, text="Журнал", padding=4)
|
log_frame = ttk.LabelFrame(outer, text="Журнал", padding=4)
|
||||||
log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 0))
|
log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 0))
|
||||||
|
log_toolbar = ttk.Frame(log_frame)
|
||||||
|
log_toolbar.pack(fill=tk.X, pady=(0, 4))
|
||||||
|
ttk.Button(
|
||||||
|
log_toolbar, text="Копировать выделение", command=self._log_copy_selection
|
||||||
|
).pack(side=tk.LEFT)
|
||||||
|
ttk.Button(
|
||||||
|
log_toolbar, text="Копировать весь журнал", command=self._log_copy_all
|
||||||
|
).pack(side=tk.LEFT, padx=(6, 0))
|
||||||
|
log_body = ttk.Frame(log_frame)
|
||||||
|
log_body.pack(fill=tk.BOTH, expand=True)
|
||||||
self.log = tk.Text(
|
self.log = tk.Text(
|
||||||
log_frame, height=7, wrap=tk.WORD, relief=tk.FLAT,
|
log_body, height=7, wrap=tk.WORD, relief=tk.FLAT,
|
||||||
bg="#fffdf8", fg=INK, padx=8, pady=6,
|
bg="#fffdf8", fg=INK, padx=8, pady=6,
|
||||||
font=("Consolas", 9) if os.name == "nt" else ("monospace", 9),
|
font=("Consolas", 9) if os.name == "nt" else ("monospace", 9),
|
||||||
|
exportselection=True, undo=False,
|
||||||
)
|
)
|
||||||
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
|
scroll = ttk.Scrollbar(log_body, command=self.log.yview)
|
||||||
self.log.configure(yscrollcommand=scroll.set)
|
self.log.configure(yscrollcommand=scroll.set)
|
||||||
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||||
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
self.log.insert("1.0", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
|
self.log.insert(
|
||||||
self.log.configure(state=tk.DISABLED)
|
"1.0",
|
||||||
|
"Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n",
|
||||||
|
)
|
||||||
|
self._log_menu = tk.Menu(self.root, tearoff=0)
|
||||||
|
self._log_menu.add_command(
|
||||||
|
label="Копировать", command=self._log_copy_selection
|
||||||
|
)
|
||||||
|
self._log_menu.add_command(
|
||||||
|
label="Копировать весь журнал", command=self._log_copy_all
|
||||||
|
)
|
||||||
|
self.log.bind("<Button-3>", self._log_context_menu)
|
||||||
|
self.log.bind("<Control-Button-1>", self._log_context_menu)
|
||||||
|
self.log.bind("<Key>", self._log_keypress)
|
||||||
|
self.log.bind("<<Copy>>", self._log_copy_event)
|
||||||
|
|
||||||
def _build_schemes_tab(self, parent) -> None:
|
def _build_schemes_tab(self, parent) -> None:
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
@@ -1697,6 +1739,10 @@ class Md2GostApp:
|
|||||||
self.toc_var.set(_display(TOC_LABELS, req.toc_mode or DEFAULT_TOC_MODE))
|
self.toc_var.set(_display(TOC_LABELS, req.toc_mode or DEFAULT_TOC_MODE))
|
||||||
self.table_var.set(_display(TABLE_LABELS, req.table_continuation or DEFAULT_TABLE_CONTINUATION))
|
self.table_var.set(_display(TABLE_LABELS, req.table_continuation or DEFAULT_TABLE_CONTINUATION))
|
||||||
self.listing_var.set(_display(TABLE_LABELS, req.listing_continuation or DEFAULT_LISTING_CONTINUATION))
|
self.listing_var.set(_display(TABLE_LABELS, req.listing_continuation or DEFAULT_LISTING_CONTINUATION))
|
||||||
|
office_engine = req.office_engine or DEFAULT_OFFICE_ENGINE
|
||||||
|
if office_engine not in GUI_OFFICE_ENGINE_MODES:
|
||||||
|
office_engine = DEFAULT_OFFICE_ENGINE
|
||||||
|
self.office_engine_var.set(_display(OFFICE_ENGINE_LABELS, office_engine))
|
||||||
self.fallback_var.set(_display(FALLBACK_LABELS, req.diagram_fallback or "remote"))
|
self.fallback_var.set(_display(FALLBACK_LABELS, req.diagram_fallback or "remote"))
|
||||||
self.format_var.set(_display(FORMAT_LABELS, req.diagram_format or "png"))
|
self.format_var.set(_display(FORMAT_LABELS, req.diagram_format or "png"))
|
||||||
scale = getattr(req, "diagram_scale", 2.0) or 2.0
|
scale = getattr(req, "diagram_scale", 2.0) or 2.0
|
||||||
@@ -1737,6 +1783,11 @@ class Md2GostApp:
|
|||||||
toc_mode=_combo_key(self.toc_var.get(), TOC_MODES, DEFAULT_TOC_MODE),
|
toc_mode=_combo_key(self.toc_var.get(), TOC_MODES, DEFAULT_TOC_MODE),
|
||||||
table_continuation=_combo_key(self.table_var.get(), TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION),
|
table_continuation=_combo_key(self.table_var.get(), TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION),
|
||||||
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
|
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
|
||||||
|
office_engine=_combo_key(
|
||||||
|
self.office_engine_var.get(),
|
||||||
|
GUI_OFFICE_ENGINE_MODES,
|
||||||
|
DEFAULT_OFFICE_ENGINE,
|
||||||
|
),
|
||||||
emdash_to_hyphen=self.emdash_var.get(),
|
emdash_to_hyphen=self.emdash_var.get(),
|
||||||
hr_pagebreak=self.hr_pagebreak_var.get(),
|
hr_pagebreak=self.hr_pagebreak_var.get(),
|
||||||
auto_title=self.auto_title_var.get(),
|
auto_title=self.auto_title_var.get(),
|
||||||
@@ -1860,17 +1911,71 @@ class Md2GostApp:
|
|||||||
self._append_log(result.message or "Ошибка")
|
self._append_log(result.message or "Ошибка")
|
||||||
messagebox.showerror("md2gost", result.message or "Ошибка конвертации")
|
messagebox.showerror("md2gost", result.message or "Ошибка конвертации")
|
||||||
|
|
||||||
|
def _log_to_clipboard(self, text: str) -> None:
|
||||||
|
self.root.clipboard_clear()
|
||||||
|
self.root.clipboard_append(text)
|
||||||
|
self.root.update_idletasks()
|
||||||
|
|
||||||
|
def _log_copy_selection(self) -> None:
|
||||||
|
try:
|
||||||
|
if not self.log.tag_ranges(tk.SEL):
|
||||||
|
return
|
||||||
|
text = self.log.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||||||
|
except tk.TclError:
|
||||||
|
return
|
||||||
|
if text:
|
||||||
|
self._log_to_clipboard(text)
|
||||||
|
|
||||||
|
def _log_copy_all(self) -> None:
|
||||||
|
text = self.log.get("1.0", tk.END).strip()
|
||||||
|
if text:
|
||||||
|
self._log_to_clipboard(text)
|
||||||
|
|
||||||
|
def _log_context_menu(self, event) -> str:
|
||||||
|
try:
|
||||||
|
self._log_menu.tk_popup(event.x_root, event.y_root)
|
||||||
|
finally:
|
||||||
|
self._log_menu.grab_release()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _log_copy_event(self, _event=None) -> str | None:
|
||||||
|
self._log_copy_selection()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _log_keypress(self, event) -> str | None:
|
||||||
|
"""Read-only log: allow selection/copy, block edits."""
|
||||||
|
if event.state & 0x4:
|
||||||
|
key = event.keysym.lower()
|
||||||
|
if key == "c":
|
||||||
|
self._log_copy_selection()
|
||||||
|
return "break"
|
||||||
|
if key == "a":
|
||||||
|
self.log.tag_add(tk.SEL, "1.0", tk.END)
|
||||||
|
self.log.mark_set(tk.INSERT, tk.END)
|
||||||
|
self.log.see(tk.INSERT)
|
||||||
|
return "break"
|
||||||
|
if key == "insert":
|
||||||
|
self._log_copy_selection()
|
||||||
|
return "break"
|
||||||
|
if event.keysym in (
|
||||||
|
"Left", "Right", "Up", "Down", "Home", "End",
|
||||||
|
"Prior", "Next", "Shift_L", "Shift_R", "Control_L", "Control_R",
|
||||||
|
"Tab", "ISO_Left_Tab",
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if not event.char:
|
||||||
|
return None
|
||||||
|
return "break"
|
||||||
|
|
||||||
def _append_log(self, text: str) -> None:
|
def _append_log(self, text: str) -> None:
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
if not hasattr(self, "log"):
|
if not hasattr(self, "log"):
|
||||||
return
|
return
|
||||||
self.log.configure(state=tk.NORMAL)
|
|
||||||
if not text.endswith("\n"):
|
if not text.endswith("\n"):
|
||||||
text += "\n"
|
text += "\n"
|
||||||
self.log.insert(tk.END, text)
|
self.log.insert(tk.END, text)
|
||||||
self.log.see(tk.END)
|
self.log.see(tk.END)
|
||||||
self.log.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.root.mainloop()
|
self.root.mainloop()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Office-suite backends for post-save table/listing continuation splits."""
|
||||||
|
|
||||||
|
from .detect import (
|
||||||
|
DEFAULT_OFFICE_ENGINE,
|
||||||
|
OFFICE_ENGINE_MODES,
|
||||||
|
OfficeInstall,
|
||||||
|
detect_office_install,
|
||||||
|
resolve_office_engine,
|
||||||
|
)
|
||||||
|
from .dispatch import fix_continuations_with_engine
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_OFFICE_ENGINE",
|
||||||
|
"OFFICE_ENGINE_MODES",
|
||||||
|
"OfficeInstall",
|
||||||
|
"detect_office_install",
|
||||||
|
"resolve_office_engine",
|
||||||
|
"fix_continuations_with_engine",
|
||||||
|
]
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Detect installed Word / LibreOffice / OpenOffice for post-save table splits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .uno_cli import soffice_cli_path
|
||||||
|
|
||||||
|
OFFICE_ENGINE_MODES = ("auto", "word", "libre", "openoffice")
|
||||||
|
DEFAULT_OFFICE_ENGINE = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OfficeInstall:
|
||||||
|
engine: str # "word" | "libre" | "openoffice"
|
||||||
|
label: str
|
||||||
|
soffice_path: str | None = None
|
||||||
|
python_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _program_dirs(kind: str) -> list[Path]:
|
||||||
|
roots: list[Path] = []
|
||||||
|
for env in ("ProgramFiles", "ProgramFiles(x86)"):
|
||||||
|
base = os.environ.get(env)
|
||||||
|
if base:
|
||||||
|
roots.append(Path(base))
|
||||||
|
if sys.platform != "win32":
|
||||||
|
roots.extend(
|
||||||
|
[
|
||||||
|
Path("/usr/lib/libreoffice"),
|
||||||
|
Path("/usr/lib64/libreoffice"),
|
||||||
|
Path("/opt/libreoffice"),
|
||||||
|
Path("/opt/openoffice4"),
|
||||||
|
Path("/Applications/LibreOffice.app/Contents"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
candidates: list[Path] = []
|
||||||
|
if kind == "libre":
|
||||||
|
for root in roots:
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
root / "LibreOffice" / "program",
|
||||||
|
root / "libreoffice" / "program",
|
||||||
|
root / "program",
|
||||||
|
root / "MacOS",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for root in roots:
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
root / "OpenOffice 4" / "program",
|
||||||
|
root / "openoffice4" / "program",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _soffice_names() -> tuple[str, ...]:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# soffice.com = CLI/headless; soffice.exe opens the Start Center GUI.
|
||||||
|
return ("soffice.com", "soffice.exe")
|
||||||
|
return ("soffice", "soffice.bin")
|
||||||
|
|
||||||
|
|
||||||
|
def _python_names() -> tuple[str, ...]:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return ("python.exe",)
|
||||||
|
return ("python", "python3")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_soffice_install(kind: str) -> OfficeInstall | None:
|
||||||
|
label = "LibreOffice" if kind == "libre" else "OpenOffice"
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for directory in _program_dirs(kind):
|
||||||
|
for soffice_name in _soffice_names():
|
||||||
|
soffice = directory / soffice_name
|
||||||
|
key = str(soffice.resolve()) if soffice.exists() else ""
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
python_path = None
|
||||||
|
for py_name in _python_names():
|
||||||
|
candidate = directory / py_name
|
||||||
|
if candidate.is_file():
|
||||||
|
python_path = str(candidate)
|
||||||
|
break
|
||||||
|
if python_path is None:
|
||||||
|
continue
|
||||||
|
return OfficeInstall(
|
||||||
|
engine=kind,
|
||||||
|
label=label,
|
||||||
|
soffice_path=soffice_cli_path(str(soffice)),
|
||||||
|
python_path=python_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
which = shutil.which("soffice") or shutil.which("soffice.bin")
|
||||||
|
if which:
|
||||||
|
program_dir = Path(which).resolve().parent
|
||||||
|
python_path = None
|
||||||
|
for py_name in _python_names():
|
||||||
|
candidate = program_dir / py_name
|
||||||
|
if candidate.is_file():
|
||||||
|
python_path = str(candidate)
|
||||||
|
break
|
||||||
|
if python_path:
|
||||||
|
lower = str(program_dir).lower()
|
||||||
|
detected = "openoffice" if "openoffice" in lower else "libre"
|
||||||
|
if kind != detected:
|
||||||
|
return None
|
||||||
|
return OfficeInstall(
|
||||||
|
engine=kind,
|
||||||
|
label=label,
|
||||||
|
soffice_path=soffice_cli_path(str(Path(which).resolve())),
|
||||||
|
python_path=python_path,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_office_install(engine: str) -> OfficeInstall | None:
|
||||||
|
"""Return install info for a concrete engine id."""
|
||||||
|
if engine == "word":
|
||||||
|
return _detect_word()
|
||||||
|
if engine == "libre":
|
||||||
|
return _find_soffice_install("libre")
|
||||||
|
if engine == "openoffice":
|
||||||
|
return _find_soffice_install("openoffice")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_word() -> OfficeInstall | None:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import win32com.client # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
word = None
|
||||||
|
try:
|
||||||
|
word = win32com.client.DispatchEx("Word.Application")
|
||||||
|
return OfficeInstall(engine="word", label="Microsoft Word")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if word is not None:
|
||||||
|
try:
|
||||||
|
word.Quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_office_engine(mode: str) -> tuple[str | None, OfficeInstall | None]:
|
||||||
|
"""
|
||||||
|
Resolve ``mode`` to a concrete engine id and optional install record.
|
||||||
|
|
||||||
|
``auto`` tries Word, then LibreOffice, then OpenOffice.
|
||||||
|
"""
|
||||||
|
if mode not in OFFICE_ENGINE_MODES:
|
||||||
|
mode = DEFAULT_OFFICE_ENGINE
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
for candidate in ("word", "libre", "openoffice"):
|
||||||
|
install = detect_office_install(candidate)
|
||||||
|
if install is not None:
|
||||||
|
return candidate, install
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
install = detect_office_install(mode)
|
||||||
|
if install is None:
|
||||||
|
return None, None
|
||||||
|
return mode, install
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Route table/listing continuation post-processing to Word or UNO backends."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..word_fix import FixResult, fix_continuations
|
||||||
|
from .detect import DEFAULT_OFFICE_ENGINE, resolve_office_engine
|
||||||
|
from .uno_fix import fix_continuations_uno
|
||||||
|
|
||||||
|
|
||||||
|
def fix_continuations_with_engine(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
engine: str = DEFAULT_OFFICE_ENGINE,
|
||||||
|
tables: bool = True,
|
||||||
|
listings: bool = True,
|
||||||
|
repeat_header: bool = False,
|
||||||
|
) -> FixResult:
|
||||||
|
"""
|
||||||
|
Split cross-page tables/listings using the selected office engine.
|
||||||
|
|
||||||
|
``engine`` is ``auto|word|libre|openoffice``. On missing backend returns
|
||||||
|
``ok=False`` with a reason; conversion should continue.
|
||||||
|
"""
|
||||||
|
resolved, install = resolve_office_engine(engine)
|
||||||
|
if resolved is None or install is None:
|
||||||
|
if engine == "auto":
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
message=(
|
||||||
|
"Пост-разрыв таблиц пропущен: не найден Word, LibreOffice "
|
||||||
|
"или OpenOffice."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
labels = {
|
||||||
|
"word": "Microsoft Word (Windows + pywin32)",
|
||||||
|
"libre": "LibreOffice (soffice + program/python)",
|
||||||
|
"openoffice": "OpenOffice (soffice + program/python)",
|
||||||
|
}
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
message=f"Пост-разрыв таблиц пропущен: нужен {labels.get(engine, engine)}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolved == "word":
|
||||||
|
return fix_continuations(
|
||||||
|
path,
|
||||||
|
tables=tables,
|
||||||
|
listings=listings,
|
||||||
|
repeat_header=repeat_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert install.soffice_path and install.python_path
|
||||||
|
return fix_continuations_uno(
|
||||||
|
path,
|
||||||
|
install=install,
|
||||||
|
tables=tables,
|
||||||
|
listings=listings,
|
||||||
|
repeat_header=repeat_header,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Open a finished DOCX with the same office engine used for post-split."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .detect import OfficeInstall, resolve_office_engine
|
||||||
|
from .uno_cli import soffice_cli_path
|
||||||
|
|
||||||
|
|
||||||
|
def open_document_with_engine(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
engine: str,
|
||||||
|
install: OfficeInstall | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Open *path* in Word / LibreOffice / OpenOffice instead of the default app."""
|
||||||
|
abs_path = os.path.abspath(path)
|
||||||
|
resolved, detected = resolve_office_engine(engine) if install is None else (engine, install)
|
||||||
|
if resolved == "word" and sys.platform == "win32":
|
||||||
|
if _open_in_word(abs_path):
|
||||||
|
return
|
||||||
|
if resolved in ("libre", "openoffice") and detected and detected.soffice_path:
|
||||||
|
if _open_in_soffice(detected.soffice_path, abs_path):
|
||||||
|
return
|
||||||
|
_open_with_os_default(abs_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_in_word(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
import win32com.client # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
word = None
|
||||||
|
try:
|
||||||
|
word = win32com.client.DispatchEx("Word.Application")
|
||||||
|
word.Visible = True
|
||||||
|
word.Documents.Open(path)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
pass # keep Word open for the user
|
||||||
|
|
||||||
|
|
||||||
|
def _open_in_soffice(soffice_path: str, path: str) -> bool:
|
||||||
|
try:
|
||||||
|
cli = soffice_cli_path(soffice_path)
|
||||||
|
subprocess.Popen(
|
||||||
|
[cli, os.path.abspath(path)],
|
||||||
|
cwd=str(Path(cli).parent),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _open_with_os_default(path: str) -> None:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Headless soffice CLI helpers (convert/import) for UNO post-process."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .uno_helpers import file_url_from_path
|
||||||
|
from .uno_profile import ensure_profile_seeded, profile_dir
|
||||||
|
|
||||||
|
|
||||||
|
def soffice_cli_path(soffice_path: str) -> str:
|
||||||
|
"""On Windows prefer ``soffice.com`` — ``soffice.exe`` opens the Start Center GUI."""
|
||||||
|
path = Path(soffice_path)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
com = path.with_name("soffice.com")
|
||||||
|
if com.is_file():
|
||||||
|
return str(com)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_args(soffice_path: str, profile_root: Path) -> list[str]:
|
||||||
|
ensure_profile_seeded(profile_root)
|
||||||
|
return [
|
||||||
|
soffice_cli_path(soffice_path),
|
||||||
|
f"-env:UserInstallation={file_url_from_path(str(profile_root))}",
|
||||||
|
"--headless",
|
||||||
|
"--nologo",
|
||||||
|
"--norestore",
|
||||||
|
"--nodefault",
|
||||||
|
"--nofirststartwizard",
|
||||||
|
"--nolockcheck",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def convert_file(
|
||||||
|
soffice_path: str,
|
||||||
|
*,
|
||||||
|
engine: str,
|
||||||
|
src: str,
|
||||||
|
out_dir: str,
|
||||||
|
convert_to: str,
|
||||||
|
timeout_s: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
"""Run ``soffice --convert-to …`` and wait until it finishes."""
|
||||||
|
profile_root = profile_dir(engine)
|
||||||
|
cmd = [
|
||||||
|
*_base_args(soffice_path, profile_root),
|
||||||
|
"--convert-to",
|
||||||
|
convert_to,
|
||||||
|
"--outdir",
|
||||||
|
os.path.abspath(out_dir),
|
||||||
|
os.path.abspath(src),
|
||||||
|
]
|
||||||
|
completed = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_s,
|
||||||
|
cwd=str(Path(soffice_path).parent),
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
tail = (completed.stderr or completed.stdout or "").strip()
|
||||||
|
if len(tail) > 500:
|
||||||
|
tail = tail[-500:]
|
||||||
|
raise RuntimeError(
|
||||||
|
f"convert-to {convert_to} failed (code {completed.returncode})"
|
||||||
|
+ (f": {tail}" if tail else "")
|
||||||
|
)
|
||||||
|
stem = Path(src).stem
|
||||||
|
expected = Path(out_dir) / f"{stem}.docx"
|
||||||
|
if convert_to.startswith("docx") and not expected.is_file():
|
||||||
|
found = sorted(Path(out_dir).glob("*.docx"))
|
||||||
|
if len(found) == 1:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"convert-to {convert_to}: output missing ({expected.name})"
|
||||||
|
+ (f", got {[p.name for p in found]}" if found else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_docx_to_odt(soffice_path: str, *, engine: str, docx_path: str, out_dir: str) -> Path:
|
||||||
|
"""OpenOffice imports DOCX more reliably via native ODT round-trip."""
|
||||||
|
for spec in ("odt", 'odt:"writer8"'):
|
||||||
|
try:
|
||||||
|
convert_file(
|
||||||
|
soffice_path,
|
||||||
|
engine=engine,
|
||||||
|
src=docx_path,
|
||||||
|
out_dir=out_dir,
|
||||||
|
convert_to=spec,
|
||||||
|
)
|
||||||
|
odt = Path(out_dir) / f"{Path(docx_path).stem}.odt"
|
||||||
|
if odt.is_file():
|
||||||
|
return odt
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"OpenOffice: не удалось импортировать DOCX → ODT: {docx_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def export_odt_to_docx(soffice_path: str, *, engine: str, odt_path: str, out_dir: str) -> Path:
|
||||||
|
# OpenOffice on Windows treats spaces in ``docx:MS Word 2007 XML`` as extra
|
||||||
|
# file arguments (Word / 2007 / XML). Use plain ``docx`` there.
|
||||||
|
if engine == "openoffice":
|
||||||
|
specs = ("docx",)
|
||||||
|
else:
|
||||||
|
specs = ("docx", "MS Word 2007 XML")
|
||||||
|
for spec in specs:
|
||||||
|
try:
|
||||||
|
convert_file(
|
||||||
|
soffice_path,
|
||||||
|
engine=engine,
|
||||||
|
src=odt_path,
|
||||||
|
out_dir=out_dir,
|
||||||
|
convert_to=spec,
|
||||||
|
)
|
||||||
|
docx = Path(out_dir) / f"{Path(odt_path).stem}.docx"
|
||||||
|
if docx.is_file():
|
||||||
|
return docx
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"OpenOffice: не удалось экспортировать ODT → DOCX: {odt_path}")
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Host-side launcher for LibreOffice/OpenOffice UNO table-split worker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..word_fix import FixResult
|
||||||
|
from .detect import OfficeInstall
|
||||||
|
from .uno_cli import export_odt_to_docx, soffice_cli_path
|
||||||
|
from .uno_helpers import file_url_from_path
|
||||||
|
from .uno_profile import ensure_profile_seeded, profile_dir
|
||||||
|
|
||||||
|
_STARTUP_TIMEOUT_S = 90.0
|
||||||
|
_WORKER_TIMEOUT_S = 600.0
|
||||||
|
_WORKER_FILES = ("uno_worker.py", "uno_helpers.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _office_program_dir(install: OfficeInstall) -> Path | None:
|
||||||
|
"""Directory with ``uno.py`` / ``pyuno`` (LibreOffice/OpenOffice ``program``)."""
|
||||||
|
for candidate in (install.python_path, install.soffice_path):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
program = Path(candidate).resolve().parent
|
||||||
|
if (program / "uno.py").is_file() or (program / "pyuno.pyd").is_file():
|
||||||
|
return program
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _uno_worker_env(worker_dir: Path, install: OfficeInstall) -> tuple[dict[str, str], Path]:
|
||||||
|
"""Build env + cwd so bundled ``program/python.exe`` can ``import uno``."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
program_dir = _office_program_dir(install)
|
||||||
|
path_parts = [str(worker_dir)]
|
||||||
|
if program_dir is not None:
|
||||||
|
path_parts.append(str(program_dir))
|
||||||
|
existing = env.get("PYTHONPATH", "")
|
||||||
|
if existing:
|
||||||
|
path_parts.append(existing)
|
||||||
|
env["PYTHONPATH"] = os.pathsep.join(path_parts)
|
||||||
|
env.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
if install.soffice_path:
|
||||||
|
env["MD2GOST_SOFFICE"] = soffice_cli_path(install.soffice_path)
|
||||||
|
cwd = program_dir if program_dir is not None else worker_dir
|
||||||
|
return env, cwd
|
||||||
|
|
||||||
|
|
||||||
|
def _office_dir() -> Path:
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_office_dir() -> Path | None:
|
||||||
|
if not getattr(sys, "frozen", False):
|
||||||
|
return None
|
||||||
|
meipass = getattr(sys, "_MEIPASS", None)
|
||||||
|
if not meipass:
|
||||||
|
return None
|
||||||
|
bundled = Path(meipass) / "md2gost" / "office"
|
||||||
|
if (bundled / "uno_worker.py").is_file():
|
||||||
|
return bundled
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_worker_dir() -> Path:
|
||||||
|
"""Return a directory with uno_worker.py (+ helpers) on disk for LO python."""
|
||||||
|
bundled = _bundled_office_dir()
|
||||||
|
if bundled is not None:
|
||||||
|
target = Path(tempfile.mkdtemp(prefix="md2gost-uno-worker-"))
|
||||||
|
for name in _WORKER_FILES:
|
||||||
|
src = bundled / name
|
||||||
|
if not src.is_file():
|
||||||
|
raise FileNotFoundError(f"UNO worker bundle incomplete: missing {src}")
|
||||||
|
shutil.copy2(src, target / name)
|
||||||
|
return target
|
||||||
|
|
||||||
|
dev_dir = _office_dir()
|
||||||
|
worker = dev_dir / "uno_worker.py"
|
||||||
|
if not worker.is_file():
|
||||||
|
raise FileNotFoundError(f"UNO worker не найден: {worker}")
|
||||||
|
return dev_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_port(port: int, _proc: subprocess.Popen[str], timeout_s: float) -> bool:
|
||||||
|
"""Wait until UNO port accepts connections.
|
||||||
|
|
||||||
|
On Windows ``soffice.exe`` is a launcher that exits after spawning
|
||||||
|
``soffice.bin``; do not treat wrapper exit as failure.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.15)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_worker_output(stdout: str) -> FixResult | None:
|
||||||
|
for line in reversed(stdout.splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if not line.startswith("{"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return FixResult(
|
||||||
|
ok=bool(payload.get("ok")),
|
||||||
|
splits=int(payload.get("splits", 0)),
|
||||||
|
skipped=int(payload.get("skipped", 0)),
|
||||||
|
message=str(payload.get("message", "")),
|
||||||
|
details=list(payload.get("details", [])),
|
||||||
|
odt_temp=payload.get("odt_temp") or None,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _export_odt_temp_to_docx(
|
||||||
|
install: OfficeInstall, odt_temp: str, docx_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Convert worker-side ODT (ASCII temp) back to target DOCX on the host (Py3)."""
|
||||||
|
time.sleep(0.5)
|
||||||
|
bridge_dir = Path(tempfile.mkdtemp(prefix="md2gost-oo-export-"))
|
||||||
|
local_odt = bridge_dir / "export.odt"
|
||||||
|
try:
|
||||||
|
shutil.copy2(odt_temp, local_odt)
|
||||||
|
engines = [install]
|
||||||
|
if install.engine == "openoffice":
|
||||||
|
from .detect import detect_office_install
|
||||||
|
|
||||||
|
libre = detect_office_install("libre")
|
||||||
|
if libre is not None and libre.soffice_path:
|
||||||
|
engines.append(libre)
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
out_docx: Path | None = None
|
||||||
|
for eng in engines:
|
||||||
|
try:
|
||||||
|
out_docx = export_odt_to_docx(
|
||||||
|
eng.soffice_path or "",
|
||||||
|
engine=eng.engine,
|
||||||
|
odt_path=str(local_odt),
|
||||||
|
out_dir=str(bridge_dir),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if out_docx is None:
|
||||||
|
raise RuntimeError(str(last_exc or "ODT export failed"))
|
||||||
|
shutil.copy2(out_docx, docx_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(bridge_dir, ignore_errors=True)
|
||||||
|
try:
|
||||||
|
os.remove(odt_temp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _soffice_server_cmd(soffice_path: str, profile_root: Path, port: int) -> list[str]:
|
||||||
|
ensure_profile_seeded(profile_root)
|
||||||
|
return [
|
||||||
|
soffice_cli_path(soffice_path),
|
||||||
|
f"-env:UserInstallation={file_url_from_path(str(profile_root))}",
|
||||||
|
"-headless",
|
||||||
|
"-invisible",
|
||||||
|
"-nologo",
|
||||||
|
"-norestore",
|
||||||
|
"-nodefault",
|
||||||
|
"-nofirststartwizard",
|
||||||
|
"-nolockcheck",
|
||||||
|
f"-accept=socket,host=127.0.0.1,port={port};urp;",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_uno_worker(
|
||||||
|
*,
|
||||||
|
install: OfficeInstall,
|
||||||
|
worker_dir: Path,
|
||||||
|
worker_path: str,
|
||||||
|
tables: bool,
|
||||||
|
listings: bool,
|
||||||
|
repeat_header: bool,
|
||||||
|
) -> FixResult:
|
||||||
|
soffice = install.soffice_path
|
||||||
|
python_exe = install.python_path
|
||||||
|
if not soffice or not python_exe:
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
message=f"Пост-разрыв ({install.label}): не задан путь к soffice/python.",
|
||||||
|
)
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
user_profile = profile_dir(install.engine)
|
||||||
|
proc: subprocess.Popen[str] | None = None
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
_soffice_server_cmd(soffice, user_profile, port),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
if not _wait_for_port(port, proc, _STARTUP_TIMEOUT_S):
|
||||||
|
hint = ""
|
||||||
|
if install.engine == "openoffice":
|
||||||
|
hint = (
|
||||||
|
" Закройте все окна OpenOffice (в т.ч. «Центр запуска») "
|
||||||
|
"и повторите."
|
||||||
|
)
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
message=(
|
||||||
|
f"Пост-разрыв ({install.label}): не удалось запустить soffice "
|
||||||
|
f"на порту {port}.{hint}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
env, worker_cwd = _uno_worker_env(worker_dir, install)
|
||||||
|
|
||||||
|
worker_cmd = [
|
||||||
|
python_exe,
|
||||||
|
str(worker_dir / "uno_worker.py"),
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--path",
|
||||||
|
worker_path,
|
||||||
|
"--engine",
|
||||||
|
install.engine,
|
||||||
|
"--tables",
|
||||||
|
"1" if tables else "0",
|
||||||
|
"--listings",
|
||||||
|
"1" if listings else "0",
|
||||||
|
"--repeat-header",
|
||||||
|
"1" if repeat_header else "0",
|
||||||
|
]
|
||||||
|
completed = subprocess.run(
|
||||||
|
worker_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=_WORKER_TIMEOUT_S,
|
||||||
|
env=env,
|
||||||
|
cwd=str(worker_cwd),
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
stdout = (completed.stdout or b"").decode("utf-8", errors="replace")
|
||||||
|
stderr = (completed.stderr or b"").decode("utf-8", errors="replace")
|
||||||
|
parsed = _parse_worker_output(stdout)
|
||||||
|
if parsed is not None:
|
||||||
|
if parsed.ok and parsed.odt_temp:
|
||||||
|
try:
|
||||||
|
_export_odt_temp_to_docx(install, parsed.odt_temp, worker_path)
|
||||||
|
parsed.details.append("bridge: ODT→DOCX (host)")
|
||||||
|
parsed.odt_temp = None
|
||||||
|
except Exception as exc:
|
||||||
|
if parsed.splits > 0:
|
||||||
|
msg = (
|
||||||
|
f"Пост-разрыв ({install.label}): разрезов {parsed.splits}, "
|
||||||
|
f"но экспорт DOCX не удался: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"Пост-разрыв ({install.label}): "
|
||||||
|
f"экспорт DOCX не удался: {exc}"
|
||||||
|
)
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
splits=parsed.splits,
|
||||||
|
skipped=parsed.skipped,
|
||||||
|
message=msg,
|
||||||
|
details=parsed.details,
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
tail = (stderr or stdout or "").strip()
|
||||||
|
if len(tail) > 400:
|
||||||
|
tail = tail[-400:]
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
message=(
|
||||||
|
f"Пост-разрыв ({install.label}) не удался "
|
||||||
|
f"(код {completed.returncode})."
|
||||||
|
+ (f" {tail}" if tail else "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return FixResult(
|
||||||
|
False,
|
||||||
|
message=f"Пост-разрыв ({install.label}): превышено время ожидания UNO worker.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if proc is not None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fix_continuations_uno(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
install: OfficeInstall,
|
||||||
|
tables: bool = True,
|
||||||
|
listings: bool = True,
|
||||||
|
repeat_header: bool = False,
|
||||||
|
) -> FixResult:
|
||||||
|
"""Run UNO worker via LibreOffice/OpenOffice bundled Python."""
|
||||||
|
abs_path = os.path.abspath(path)
|
||||||
|
if not os.path.isfile(abs_path):
|
||||||
|
return FixResult(False, message=f"Файл не найден: {abs_path}")
|
||||||
|
|
||||||
|
worker_dir: Path | None = None
|
||||||
|
try:
|
||||||
|
worker_dir = _prepare_worker_dir()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
return FixResult(False, message=str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _run_uno_worker(
|
||||||
|
install=install,
|
||||||
|
worker_dir=worker_dir,
|
||||||
|
worker_path=abs_path,
|
||||||
|
tables=tables,
|
||||||
|
listings=listings,
|
||||||
|
repeat_header=repeat_header,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if worker_dir is not None and _bundled_office_dir() is not None:
|
||||||
|
shutil.rmtree(worker_dir, ignore_errors=True)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Pure table-split helpers for UNO worker (Py2.7 + Py3, standalone, no pywin32)."""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote
|
||||||
|
except ImportError:
|
||||||
|
from urllib import quote as _urllib_quote
|
||||||
|
|
||||||
|
def quote(value, safe="/:@"):
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode("utf-8")
|
||||||
|
return _urllib_quote(value.encode("utf-8"), safe)
|
||||||
|
|
||||||
|
|
||||||
|
DOCX_FILTER_CANDIDATES = {
|
||||||
|
"libre": ("Office Open XML Text", "MS Word 2007 XML", "Word 2007 XML"),
|
||||||
|
"openoffice": (
|
||||||
|
"MS Word 2007 XML",
|
||||||
|
"Office Open XML Text",
|
||||||
|
"Word 2007 XML",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCX_SAVE_FILTER_SPECS = {
|
||||||
|
"libre": (
|
||||||
|
"Office Open XML Text",
|
||||||
|
"MS Word 2007 XML",
|
||||||
|
),
|
||||||
|
"openoffice": (
|
||||||
|
"MS Word 2007 XML",
|
||||||
|
"Office Open XML Text",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_PASSES = 20
|
||||||
|
|
||||||
|
# UNO enum numeric values (``from com.sun.star.*`` is unavailable in some LO/OO Pythons).
|
||||||
|
BREAK_NONE = 0
|
||||||
|
BREAK_PAGE_BEFORE = 3
|
||||||
|
CONTROL_PARAGRAPH_BREAK = 0
|
||||||
|
|
||||||
|
_UNO_MISSING = object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_TEXT_TYPE = unicode
|
||||||
|
except NameError:
|
||||||
|
_TEXT_TYPE = str
|
||||||
|
|
||||||
|
|
||||||
|
def as_unicode(value):
|
||||||
|
"""Coerce value to a Unicode string (safe on Python 2.7 + 3)."""
|
||||||
|
if value is None:
|
||||||
|
return _TEXT_TYPE()
|
||||||
|
if isinstance(value, _TEXT_TYPE):
|
||||||
|
return value
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
for encoding in ("utf-8", sys.getfilesystemencoding(), "mbcs"):
|
||||||
|
if not encoding:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return value.decode(encoding)
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
continue
|
||||||
|
return value.decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
return _TEXT_TYPE(value)
|
||||||
|
except Exception:
|
||||||
|
return _TEXT_TYPE(repr(value))
|
||||||
|
|
||||||
|
|
||||||
|
def uno_error_text(exc):
|
||||||
|
"""Readable UNO/Python exception text (Py2 often has empty str(exc))."""
|
||||||
|
for attr in ("Message", "Context", "Name"):
|
||||||
|
try:
|
||||||
|
val = getattr(exc, attr, None)
|
||||||
|
if val:
|
||||||
|
text = as_unicode(val)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
text = as_unicode(exc)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return repr(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def uno_call(obj, base, default=_UNO_MISSING):
|
||||||
|
"""Invoke UNO getter ``getBase()`` or read attribute ``Base`` (OO Py2 vs LO Py3)."""
|
||||||
|
getter = "get" + base
|
||||||
|
for access in (getter, base):
|
||||||
|
try:
|
||||||
|
member = getattr(obj, access)
|
||||||
|
return member() if callable(member) else member
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if default is not _UNO_MISSING:
|
||||||
|
return default
|
||||||
|
raise AttributeError(base)
|
||||||
|
|
||||||
|
_RE_FLAGS = re.IGNORECASE
|
||||||
|
if sys.version_info[0] < 3:
|
||||||
|
_RE_FLAGS |= re.UNICODE
|
||||||
|
|
||||||
|
_CAPTION_RE = re.compile(
|
||||||
|
r"^(?:Продолжение\s+)?"
|
||||||
|
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
|
||||||
|
r"(?P<number>[\d.]+)",
|
||||||
|
_RE_FLAGS,
|
||||||
|
)
|
||||||
|
_CONTINUATION_RE = re.compile(
|
||||||
|
r"^Продолжение\s+(?:Таблицы|Листинга)\b", _RE_FLAGS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptionInfo(object):
|
||||||
|
__slots__ = ("kind", "number", "is_continuation")
|
||||||
|
|
||||||
|
def __init__(self, kind, number, is_continuation=False):
|
||||||
|
self.kind = kind
|
||||||
|
self.number = number
|
||||||
|
self.is_continuation = is_continuation
|
||||||
|
|
||||||
|
|
||||||
|
def parse_caption_text(text):
|
||||||
|
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):
|
||||||
|
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 is_orphan_header_break(break_at, had_header):
|
||||||
|
return bool(had_header and break_at == 2)
|
||||||
|
|
||||||
|
|
||||||
|
def is_caption_orphaned(caption_page, first_row_page):
|
||||||
|
if caption_page is None or first_row_page is None:
|
||||||
|
return False
|
||||||
|
return caption_page < first_row_page
|
||||||
|
|
||||||
|
|
||||||
|
def continuation_label(kind, number):
|
||||||
|
if kind == "listing":
|
||||||
|
return "Продолжение Листинга {0}".format(number)
|
||||||
|
return "Продолжение Таблицы {0}".format(number)
|
||||||
|
|
||||||
|
|
||||||
|
def caption_style_name(kind):
|
||||||
|
return "Caption Listing" if kind == "listing" else "Название таблицы"
|
||||||
|
|
||||||
|
|
||||||
|
def file_url_from_path(path):
|
||||||
|
abs_path = os.path.abspath(path)
|
||||||
|
posix = abs_path.replace("\\", "/")
|
||||||
|
if len(posix) >= 2 and posix[1] == ":":
|
||||||
|
return "file:///" + quote(posix, safe="/:@")
|
||||||
|
return "file://" + quote(posix, safe="/:@")
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_file_url(uno, path):
|
||||||
|
abs_path = os.path.abspath(path)
|
||||||
|
try:
|
||||||
|
url = uno.systemPathToFileUrl(abs_path)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return file_url_from_path(abs_path)
|
||||||
|
|
||||||
|
|
||||||
|
def docx_filter_candidates(engine):
|
||||||
|
return DOCX_FILTER_CANDIDATES.get(engine, DOCX_FILTER_CANDIDATES["libre"])
|
||||||
|
|
||||||
|
|
||||||
|
def docx_save_filter_specs(engine):
|
||||||
|
return DOCX_SAVE_FILTER_SPECS.get(engine, DOCX_SAVE_FILTER_SPECS["libre"])
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Persistent LibreOffice/OpenOffice user profiles for headless UNO runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Marks first-start / registration wizard as done (OpenOffice + LibreOffice).
|
||||||
|
_REGISTRY_SEED = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="Setup" oor:package="org.openoffice.Office">
|
||||||
|
<node oor:name="Office">
|
||||||
|
<prop oor:name="ooSetupCompleted" oor:type="xs:boolean">
|
||||||
|
<value>true</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="FirstStartWizardCompleted" oor:type="xs:boolean">
|
||||||
|
<value>true</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
</oor:component-data>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def profile_dir(engine: str) -> Path:
|
||||||
|
"""Writable profile reused across conversions (avoids OO registration every run)."""
|
||||||
|
root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or tempfile.gettempdir()
|
||||||
|
path = Path(root) / "md2gost" / "office-profiles" / engine
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_profile_seeded(profile_root: Path) -> None:
|
||||||
|
"""Pre-seed registry so OpenOffice does not show the registration wizard."""
|
||||||
|
marker = profile_root / ".md2gost-seeded"
|
||||||
|
user_dir = profile_root / "user"
|
||||||
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
xcu = user_dir / "registrymodifications.xcu"
|
||||||
|
if not xcu.is_file():
|
||||||
|
xcu.write_text(_REGISTRY_SEED, encoding="utf-8")
|
||||||
|
if not marker.is_file():
|
||||||
|
marker.write_text("1", encoding="utf-8")
|
||||||
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -17,6 +17,7 @@ from docx import Document
|
|||||||
from . import package_dir
|
from . import package_dir
|
||||||
from .checker import check_markdown, format_report
|
from .checker import check_markdown, format_report
|
||||||
from .converter import Converter
|
from .converter import Converter
|
||||||
|
from .office import DEFAULT_OFFICE_ENGINE
|
||||||
from .profiles import (
|
from .profiles import (
|
||||||
DEFAULT_HEADING_NUMBERING,
|
DEFAULT_HEADING_NUMBERING,
|
||||||
DEFAULT_TABLE_CONTINUATION,
|
DEFAULT_TABLE_CONTINUATION,
|
||||||
@@ -60,6 +61,7 @@ class ConvertRequest:
|
|||||||
open_when_done: bool = False
|
open_when_done: bool = False
|
||||||
check_pages: bool = False
|
check_pages: bool = False
|
||||||
table_repeat_header: bool = False
|
table_repeat_header: bool = False
|
||||||
|
office_engine: str = DEFAULT_OFFICE_ENGINE
|
||||||
# PAGE field start on body section; None = continuous from document start.
|
# PAGE field start on body section; None = continuous from document start.
|
||||||
page_number_start: int | None = None
|
page_number_start: int | None = None
|
||||||
# DOCX File → Info; None = take from md2gost.user.json (see doc_metadata).
|
# DOCX File → Info; None = take from md2gost.user.json (see doc_metadata).
|
||||||
@@ -425,12 +427,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
|||||||
abs_out = os.path.abspath(output)
|
abs_out = os.path.abspath(output)
|
||||||
emit(f"Generated document: {abs_out}")
|
emit(f"Generated document: {abs_out}")
|
||||||
|
|
||||||
|
split_engine: str | None = None
|
||||||
|
split_install = None
|
||||||
if req.table_continuation == "word" or req.listing_continuation == "word":
|
if req.table_continuation == "word" or req.listing_continuation == "word":
|
||||||
from .word_fix import fix_continuations
|
from .office import fix_continuations_with_engine, resolve_office_engine
|
||||||
from .page_geometry import patch_docx_page_starts
|
from .page_geometry import patch_docx_page_starts
|
||||||
|
|
||||||
fix = fix_continuations(
|
split_engine, split_install = resolve_office_engine(req.office_engine)
|
||||||
|
fix = fix_continuations_with_engine(
|
||||||
abs_out,
|
abs_out,
|
||||||
|
engine=req.office_engine,
|
||||||
tables=(req.table_continuation == "word"),
|
tables=(req.table_continuation == "word"),
|
||||||
listings=(req.listing_continuation == "word"),
|
listings=(req.listing_continuation == "word"),
|
||||||
repeat_header=bool(req.table_repeat_header),
|
repeat_header=bool(req.table_repeat_header),
|
||||||
@@ -463,7 +469,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
|||||||
|
|
||||||
if req.debug or req.open_when_done:
|
if req.debug or req.open_when_done:
|
||||||
try:
|
try:
|
||||||
open_document(abs_out)
|
if split_engine in ("word", "libre", "openoffice"):
|
||||||
|
from .office.open_doc import open_document_with_engine
|
||||||
|
|
||||||
|
open_document_with_engine(
|
||||||
|
abs_out,
|
||||||
|
engine=split_engine,
|
||||||
|
install=split_install,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
open_document(abs_out)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
emit(f"Не удалось открыть файл: {exc}")
|
emit(f"Не удалось открыть файл: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class FixResult:
|
|||||||
skipped: int = 0
|
skipped: int = 0
|
||||||
message: str = ""
|
message: str = ""
|
||||||
details: list[str] = field(default_factory=list)
|
details: list[str] = field(default_factory=list)
|
||||||
|
odt_temp: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def parse_caption_text(text: str) -> CaptionInfo | None:
|
def parse_caption_text(text: str) -> CaptionInfo | None:
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ packages = ["md2gost", "mdexport", "md2latex", "md2fodt", "word2md"]
|
|||||||
"md2gost/TitleTemplate.docx" = "md2gost/TitleTemplate.docx"
|
"md2gost/TitleTemplate.docx" = "md2gost/TitleTemplate.docx"
|
||||||
"md2gost/mml2omml" = "md2gost/mml2omml"
|
"md2gost/mml2omml" = "md2gost/mml2omml"
|
||||||
"md2gost/diagrams" = "md2gost/diagrams"
|
"md2gost/diagrams" = "md2gost/diagrams"
|
||||||
|
"md2gost/office/uno_worker.py" = "md2gost/office/uno_worker.py"
|
||||||
|
"md2gost/office/uno_helpers.py" = "md2gost/office/uno_helpers.py"
|
||||||
"latex/mirea" = "latex/mirea"
|
"latex/mirea" = "latex/mirea"
|
||||||
"prompts/generate-md.md" = "md2gost/prompts/generate-md.md"
|
"prompts/generate-md.md" = "md2gost/prompts/generate-md.md"
|
||||||
"prompts/generate-mirea-report.md" = "md2gost/prompts/generate-mirea-report.md"
|
"prompts/generate-mirea-report.md" = "md2gost/prompts/generate-mirea-report.md"
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Tests for office-engine detection and dispatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from md2gost.office import (
|
||||||
|
DEFAULT_OFFICE_ENGINE,
|
||||||
|
OFFICE_ENGINE_MODES,
|
||||||
|
OfficeInstall,
|
||||||
|
detect_office_install,
|
||||||
|
fix_continuations_with_engine,
|
||||||
|
resolve_office_engine,
|
||||||
|
)
|
||||||
|
from md2gost.office import uno_fix
|
||||||
|
from md2gost.office import uno_profile
|
||||||
|
from md2gost.office.uno_cli import soffice_cli_path
|
||||||
|
from md2gost.office.uno_helpers import (
|
||||||
|
as_unicode,
|
||||||
|
docx_filter_candidates,
|
||||||
|
file_url_from_path,
|
||||||
|
uno_call,
|
||||||
|
)
|
||||||
|
from md2gost.__main__ import build_parser, request_from_args
|
||||||
|
from md2gost.word_fix import FixResult
|
||||||
|
|
||||||
|
|
||||||
|
def test_office_engine_modes():
|
||||||
|
assert DEFAULT_OFFICE_ENGINE == "auto"
|
||||||
|
assert OFFICE_ENGINE_MODES == ("auto", "word", "libre", "openoffice")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_office_engine_default():
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["report.md"])
|
||||||
|
req = request_from_args(args)
|
||||||
|
assert req.office_engine == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_office_engine_libre():
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["report.md", "--office-engine", "libre"])
|
||||||
|
req = request_from_args(args)
|
||||||
|
assert req.office_engine == "libre"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("md2gost.office.detect._detect_word")
|
||||||
|
@patch("md2gost.office.detect._find_soffice_install")
|
||||||
|
def test_resolve_auto_prefers_word(mock_lo, mock_word):
|
||||||
|
mock_lo.return_value = OfficeInstall(
|
||||||
|
engine="libre",
|
||||||
|
label="LibreOffice",
|
||||||
|
soffice_path=r"C:\LibreOffice\program\soffice.exe",
|
||||||
|
python_path=r"C:\LibreOffice\program\python.exe",
|
||||||
|
)
|
||||||
|
mock_word.return_value = OfficeInstall(engine="word", label="Microsoft Word")
|
||||||
|
engine, install = resolve_office_engine("auto")
|
||||||
|
assert engine == "word"
|
||||||
|
assert install is not None
|
||||||
|
assert install.engine == "word"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("md2gost.office.detect._detect_word")
|
||||||
|
@patch("md2gost.office.detect._find_soffice_install")
|
||||||
|
def test_resolve_auto_falls_back_to_libre(mock_lo, mock_word):
|
||||||
|
mock_word.return_value = None
|
||||||
|
mock_lo.return_value = OfficeInstall(
|
||||||
|
engine="libre",
|
||||||
|
label="LibreOffice",
|
||||||
|
soffice_path=r"C:\LibreOffice\program\soffice.exe",
|
||||||
|
python_path=r"C:\LibreOffice\program\python.exe",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _lo(kind: str):
|
||||||
|
return mock_lo.return_value if kind == "libre" else None
|
||||||
|
|
||||||
|
with patch("md2gost.office.detect._find_soffice_install", side_effect=_lo):
|
||||||
|
engine, install = resolve_office_engine("auto")
|
||||||
|
assert engine == "libre"
|
||||||
|
assert install is not None
|
||||||
|
assert install.engine == "libre"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("md2gost.office.detect._detect_word")
|
||||||
|
@patch("md2gost.office.detect._find_soffice_install")
|
||||||
|
def test_resolve_auto_missing(mock_lo, mock_word):
|
||||||
|
mock_word.return_value = None
|
||||||
|
mock_lo.return_value = None
|
||||||
|
engine, install = resolve_office_engine("auto")
|
||||||
|
assert engine is None
|
||||||
|
assert install is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_libre_from_custom_path(tmp_path):
|
||||||
|
program = tmp_path / "program"
|
||||||
|
program.mkdir()
|
||||||
|
soffice = program / ("soffice.exe" if sys.platform == "win32" else "soffice")
|
||||||
|
python_bin = program / ("python.exe" if sys.platform == "win32" else "python")
|
||||||
|
soffice.write_text("", encoding="utf-8")
|
||||||
|
python_bin.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("md2gost.office.detect._program_dirs", return_value=[program]):
|
||||||
|
install = detect_office_install("libre")
|
||||||
|
assert install is not None
|
||||||
|
assert install.engine == "libre"
|
||||||
|
assert install.soffice_path == str(soffice)
|
||||||
|
assert install.python_path == str(python_bin)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("md2gost.office.dispatch.fix_continuations")
|
||||||
|
@patch("md2gost.office.dispatch.resolve_office_engine")
|
||||||
|
def test_dispatch_word_backend(mock_resolve, mock_word_fix):
|
||||||
|
mock_resolve.return_value = ("word", OfficeInstall(engine="word", label="Microsoft Word"))
|
||||||
|
mock_word_fix.return_value = FixResult(True, splits=2, message="ok")
|
||||||
|
result = fix_continuations_with_engine("report.docx", engine="word")
|
||||||
|
assert result.ok is True
|
||||||
|
mock_word_fix.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("md2gost.office.dispatch.fix_continuations_uno")
|
||||||
|
@patch("md2gost.office.dispatch.resolve_office_engine")
|
||||||
|
def test_dispatch_libre_backend(mock_resolve, mock_uno):
|
||||||
|
install = OfficeInstall(
|
||||||
|
engine="libre",
|
||||||
|
label="LibreOffice",
|
||||||
|
soffice_path=r"C:\LibreOffice\program\soffice.exe",
|
||||||
|
python_path=r"C:\LibreOffice\program\python.exe",
|
||||||
|
)
|
||||||
|
mock_resolve.return_value = ("libre", install)
|
||||||
|
mock_uno.return_value = FixResult(True, splits=1, message="uno ok")
|
||||||
|
result = fix_continuations_with_engine("report.docx", engine="libre")
|
||||||
|
assert result.ok is True
|
||||||
|
mock_uno.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("md2gost.office.dispatch.resolve_office_engine")
|
||||||
|
def test_dispatch_missing_engine(mock_resolve):
|
||||||
|
mock_resolve.return_value = (None, None)
|
||||||
|
result = fix_continuations_with_engine("report.docx", engine="libre")
|
||||||
|
assert result.ok is False
|
||||||
|
assert "пропущен" in result.message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_soffice_cli_path_prefers_com_on_windows(tmp_path):
|
||||||
|
program = tmp_path / "program"
|
||||||
|
program.mkdir()
|
||||||
|
exe = program / "soffice.exe"
|
||||||
|
com = program / "soffice.com"
|
||||||
|
exe.write_text("", encoding="utf-8")
|
||||||
|
com.write_text("", encoding="utf-8")
|
||||||
|
if sys.platform == "win32":
|
||||||
|
assert soffice_cli_path(str(exe)) == str(com)
|
||||||
|
else:
|
||||||
|
assert soffice_cli_path(str(exe)) == str(exe)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_url_from_path_cyrillic():
|
||||||
|
url = file_url_from_path(r"G:\Sync_File\Практическая работа 2.test.docx")
|
||||||
|
assert url.startswith("file:///")
|
||||||
|
assert "%" in url or "Практическая" in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_filters_openoffice_first():
|
||||||
|
assert docx_filter_candidates("openoffice")[0] == "MS Word 2007 XML"
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_seeded_once(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
root = uno_profile.profile_dir("openoffice")
|
||||||
|
uno_profile.ensure_profile_seeded(root)
|
||||||
|
xcu = root / "user" / "registrymodifications.xcu"
|
||||||
|
assert xcu.is_file()
|
||||||
|
assert "FirstStartWizardCompleted" in xcu.read_text(encoding="utf-8")
|
||||||
|
mtime = xcu.stat().st_mtime
|
||||||
|
uno_profile.ensure_profile_seeded(root)
|
||||||
|
assert xcu.stat().st_mtime == mtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_as_unicode_utf8_bytes():
|
||||||
|
raw = "Практическая".encode("utf-8")
|
||||||
|
text = as_unicode(raw)
|
||||||
|
assert "Практ" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_uno_call_method_or_attribute():
|
||||||
|
class _DocMethod:
|
||||||
|
def getText(self):
|
||||||
|
return "method"
|
||||||
|
|
||||||
|
class _DocAttr:
|
||||||
|
Text = "attr"
|
||||||
|
|
||||||
|
assert uno_call(_DocMethod(), "Text") == "method"
|
||||||
|
assert uno_call(_DocAttr(), "Text") == "attr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uno_worker_env_includes_program_dir():
|
||||||
|
install = OfficeInstall(
|
||||||
|
engine="openoffice",
|
||||||
|
label="OpenOffice",
|
||||||
|
soffice_path=r"C:\OpenOffice 4\program\soffice.exe",
|
||||||
|
python_path=r"C:\OpenOffice 4\program\python.exe",
|
||||||
|
)
|
||||||
|
worker_dir = Path(r"C:\temp\worker")
|
||||||
|
with patch.object(uno_fix, "_office_program_dir", return_value=Path(r"C:\OpenOffice 4\program")):
|
||||||
|
env, cwd = uno_fix._uno_worker_env(worker_dir, install)
|
||||||
|
assert r"C:\OpenOffice 4\program" in env["PYTHONPATH"]
|
||||||
|
assert str(worker_dir) in env["PYTHONPATH"]
|
||||||
|
assert cwd == Path(r"C:\OpenOffice 4\program")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_worker_dir_dev():
|
||||||
|
worker_dir = uno_fix._prepare_worker_dir()
|
||||||
|
assert (worker_dir / "uno_worker.py").is_file()
|
||||||
|
assert (worker_dir / "uno_helpers.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_worker_dir_frozen_extracts_to_temp(tmp_path, monkeypatch):
|
||||||
|
bundled = tmp_path / "md2gost" / "office"
|
||||||
|
bundled.mkdir(parents=True)
|
||||||
|
(bundled / "uno_worker.py").write_text("# worker\n", encoding="utf-8")
|
||||||
|
(bundled / "uno_helpers.py").write_text("# helpers\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||||
|
monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False)
|
||||||
|
|
||||||
|
worker_dir = uno_fix._prepare_worker_dir()
|
||||||
|
assert worker_dir != bundled
|
||||||
|
assert (worker_dir / "uno_worker.py").read_text(encoding="utf-8") == "# worker\n"
|
||||||
|
assert (worker_dir / "uno_helpers.py").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.environ.get("MD2GOST_TEST_LIBRE") != "1", reason="set MD2GOST_TEST_LIBRE=1")
|
||||||
|
def test_libre_uno_smoke(tmp_path):
|
||||||
|
install = detect_office_install("libre")
|
||||||
|
if install is None:
|
||||||
|
pytest.skip("LibreOffice not installed")
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt
|
||||||
|
|
||||||
|
path = tmp_path / "tall_table.docx"
|
||||||
|
doc = Document()
|
||||||
|
doc.add_paragraph("Таблица 1 — Длинная")
|
||||||
|
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_with_engine(
|
||||||
|
str(path),
|
||||||
|
engine="libre",
|
||||||
|
tables=True,
|
||||||
|
listings=False,
|
||||||
|
)
|
||||||
|
assert result.ok, result.message
|
||||||
|
assert path.is_file()
|
||||||
Reference in New Issue
Block a user