BigUpdate 0.5.0
Python application / build (push) Has been cancelled

- add Local Render Mermaid
- add page-starе для указания смещения страниц
- add Гиперссылки в документе на списки литературы
This commit is contained in:
Igor20264
2026-09-07 09:54:33 +03:00
parent ac0aa33399
commit 1a5b35eb54
29 changed files with 4118 additions and 69 deletions
+11 -4
View File
@@ -104,7 +104,7 @@ python -m md2gost report.md -o report.docx --toc manual
| Листинг | `%id Подпись` перед code fence |
| Диаграмма UML / Mermaid / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````bpmn` / ````mermaid` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json`. IDEF0 нет |
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
| Источник | `[1]` в тексте; `[1]: …` в списке |
| Источник | `[1]` в тексте (кликабельная ссылка на пункт списка); `[1]: …` в списке |
| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) |
### Таблицы со склеиванием
@@ -208,7 +208,7 @@ 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.
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`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** конвертер не рисует — вставляйте готовый PNG.
```bpmn
StartMessage(s, "заявка")
@@ -243,9 +243,14 @@ DefaultFlow(gw, e_no)
2. Локальный Kroki (`KROKI_URL` / `--kroki-url`, по умолчанию `http://localhost:8000`)
3. Интернет `https://kroki.io` при `--diagram-fallback remote` (по умолчанию)
Mermaid всегда идёт через Kroki (шаги 2–3).
Mermaid:
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML рендерится с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
1. Системный Chrome / Edge (Playwright channel, ничего не тащим)
2. Playwright Chromium в `%LOCALAPPDATA%\md2gost\ms-playwright` — GUI «Скачать headless Chromium» или `md2gost --install-chromium`
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML и локальный Mermaid с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная.
@@ -255,6 +260,8 @@ Mermaid всегда идёт через Kroki (шаги 2–3).
```bash
python scripts/fetch_plantuml.py
python scripts/fetch_mermaid.py
python -m md2gost --install-chromium
python -m md2gost report.md -o report.docx --diagram-fallback local
python -m md2gost report.md -o report.docx --diagram-format svg
```
+49 -2
View File
@@ -147,7 +147,7 @@ def build_parser() -> ArgumentParser:
"--diagram-fallback",
choices=["local", "remote", "off"],
default="remote",
help="Если локальный рендер UML недоступен: remote (kroki.io), local (ошибка), off",
help="Если локальный рендер недоступен: remote (kroki.io), local (ошибка), off",
)
parser.add_argument(
"--diagram-format",
@@ -159,7 +159,28 @@ def build_parser() -> ArgumentParser:
"--diagram-scale",
type=float,
default=2.0,
help="Масштаб рендера PlantUML PNG (качество); размер на странице как при 1. По умолчанию 2",
help=(
"Масштаб растра PlantUML / локального Mermaid (качество); "
"размер на странице как при 1. По умолчанию 2"
),
)
parser.add_argument(
"--page-start",
type=int,
default=None,
metavar="N",
help=(
"Смещение / начальный номер страницы в колонтитуле тела документа. "
"Пусто — сквозная нумерация Word; например 1 — тело начинается с 1"
),
)
parser.add_argument(
"--install-chromium",
action="store_true",
help=(
"Скачать headless Chromium для локального Mermaid "
"в %%LOCALAPPDATA%%\\md2gost\\ms-playwright (и выйти)"
),
)
parser.add_argument(
"--schemes",
@@ -206,6 +227,11 @@ def request_from_args(args) -> ConvertRequest:
diagram_scale=float(args.diagram_scale),
schemes_path=args.schemes_path,
styles_path=getattr(args, "styles_path", None),
page_number_start=(
int(args.page_start)
if getattr(args, "page_start", None) is not None and int(args.page_start) >= 1
else None
),
debug=args.debug,
open_when_done=bool(args.debug),
)
@@ -218,6 +244,8 @@ def _argv_needs_console(argv: list[str]) -> bool:
return False
if "-h" in args or "--help" in args:
return True
if "--install-chromium" in args:
return True
if "--gui" in args:
return False
return any(not a.startswith("-") for a in args)
@@ -247,6 +275,25 @@ def main():
_enable_windows_console()
parser = build_parser()
args = parser.parse_args()
if getattr(args, "install_chromium", False):
from .mermaid_renderer import (
install_playwright_chromium,
playwright_available,
playwright_browsers_dir,
)
if not playwright_available():
print("playwright не установлен. pip install playwright", file=sys.stderr)
sys.exit(1)
print(f"Устанавливаю Chromium → {playwright_browsers_dir()}")
ok = install_playwright_chromium()
if ok:
print("Готово.")
sys.exit(0)
print("Не удалось установить Chromium.", file=sys.stderr)
sys.exit(1)
req = request_from_args(args)
if should_launch_gui(args.filename, args.gui):
+5
View File
@@ -16,6 +16,11 @@ CITE_RE = re.compile(
)
def biblio_bookmark_name(key: str) -> str:
"""Word bookmark name for bibliography key (dots → underscores)."""
return "biblio_" + str(key).replace(".", "_")
@dataclass
class BiblioEntry:
key: str
+4 -1
View File
@@ -41,13 +41,16 @@ class Bibliography(Renderable):
@staticmethod
def _make_entry(parent: Parented, e: BiblioEntry) -> Paragraph:
from .bibliography import biblio_bookmark_name
p = Paragraph(parent)
try:
p.style = "Bibliography"
except KeyError:
pass
# Avoid hyphen-splitting URLs: put whole entry in one run via direct text
run = p._docx_paragraph.add_run(f"{e.key}. {e.text}")
p._docx_paragraph.add_run(f"{e.key}. {e.text}")
p.wrap_with_bookmark(biblio_bookmark_name(e.key))
return p
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
+125 -25
View File
@@ -94,6 +94,13 @@ def configure_diagrams(
global _CONFIG
ensure_user_schemes()
configure_schemes(schemes_path=schemes_path, md_dir=md_dir)
try:
from .mermaid_renderer import ensure_playwright_browsers_env, reset_mermaid_session
ensure_playwright_browsers_env()
reset_mermaid_session()
except Exception:
pass
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:
@@ -128,13 +135,19 @@ def _cache_root(cache_dir: str | None = None) -> Path:
return path
def _cache_digest(source: str) -> str:
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
def _cache_digest(source: str, *, cache_prefix: str = "") -> str:
payload = (cache_prefix + source) if cache_prefix else source
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
def _cache_paths(source: str, cache_dir: str | None = None) -> tuple[Path, Path]:
def _cache_paths(
source: str,
cache_dir: str | None = None,
*,
cache_prefix: str = "",
) -> tuple[Path, Path]:
root = _cache_root(cache_dir)
digest = _cache_digest(source)
digest = _cache_digest(source, cache_prefix=cache_prefix)
return root / f"{digest}.png", root / f"{digest}.svg"
@@ -235,7 +248,14 @@ def diagram_engine_status(explicit_jar: str | None = None) -> str:
"PlantUML: файла нет. Нажмите «Скачать PlantUML» (нужна Java) "
"или оставьте как есть — картинки нарисует интернет (kroki.io)."
)
return java_line + "\n" + jar_line
lines = java_line + "\n" + jar_line
try:
from .mermaid_renderer import mermaid_engine_status
lines = lines + "\n" + mermaid_engine_status()
except Exception as exc:
lines = lines + f"\nMermaid: статус недоступен ({exc})"
return lines
def _ensure_plantuml_png_scale(source: str, scale: float = DEFAULT_DIAGRAM_SCALE) -> str:
@@ -394,7 +414,32 @@ def render_diagram(
prepared = _ensure_plantuml_png_scale(prepared, scale)
if prepared != before:
pixel_scale = scale
out_png, out_svg_path = _cache_paths(prepared, cache_dir)
# Mermaid: pixel_scale set only after successful local hi-res render (not Kroki).
cache_prefix = ""
if kroki_type == "mermaid":
try:
from .mermaid_renderer import CACHE_KEY_PREFIX
cache_prefix = CACHE_KEY_PREFIX
except Exception:
cache_prefix = "mmd-local-v4\n"
# Mermaid local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
local_prefix = cache_prefix
if kroki_type == "mermaid" and scale > 1:
local_prefix = f"{cache_prefix}scale={scale:g}\n"
out_png, out_svg_path = _cache_paths(
prepared, cache_dir, cache_prefix=local_prefix if kroki_type == "mermaid" else cache_prefix,
)
# Kroki / remote paths (mermaid): never share the scaled local key.
kroki_png, kroki_svg_path = out_png, out_svg_path
if kroki_type == "mermaid":
kroki_png, kroki_svg_path = _cache_paths(
prepared, cache_dir, cache_prefix=cache_prefix,
)
fmt: DiagramFormat = (
diagram_format
if diagram_format is not None
@@ -402,29 +447,57 @@ def render_diagram(
)
want_svg = fmt == "svg"
svg_target = out_svg_path if want_svg else None
kroki_svg_target = kroki_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:
hit_scale = pixel_scale
if kroki_type == "mermaid" and scale > 1 and local_prefix != cache_prefix:
hit_scale = scale
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,
pixel_scale=hit_scale,
)
# PNG cached but SVG missing in svg mode — fall through to fill SVG.
# Mermaid: also accept unscaled Kroki cache hit
if (
kroki_type == "mermaid"
and kroki_png != out_png
and kroki_png.is_file()
and kroki_png.stat().st_size > 0
):
has_svg = kroki_svg_path.is_file() and kroki_svg_path.stat().st_size > 0
if not want_svg or has_svg:
return DiagramRenderResult(
png_path=str(kroki_png),
svg_path=str(kroki_svg_path) if (want_svg and has_svg) else None,
pixel_scale=1.0,
)
jar = plantuml_jar if plantuml_jar is not None else _CONFIG.plantuml_jar
jar = resolve_plantuml_jar(jar or None, download=False)
local_kroki = kroki_url if kroki_url is not None else _CONFIG.kroki_url
local_kroki = local_kroki or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL
mode: FallbackMode = fallback if fallback is not None else _CONFIG.fallback
def _result() -> DiagramRenderResult:
def _result(
png: Path | None = None,
svg_path: Path | None = None,
*,
pscale: float | None = None,
) -> DiagramRenderResult:
use_png = png or out_png
use_svg_path = svg_path if svg_path is not None else out_svg_path
svg = None
if want_svg and out_svg_path.is_file() and out_svg_path.stat().st_size > 0:
svg = str(out_svg_path)
if want_svg and use_svg_path.is_file() and use_svg_path.stat().st_size > 0:
svg = str(use_svg_path)
return DiagramRenderResult(
png_path=str(out_png), svg_path=svg, pixel_scale=pixel_scale,
png_path=str(use_png),
svg_path=svg,
pixel_scale=pixel_scale if pscale is None else pscale,
)
# 1) PlantUML jar — only for plantuml type
@@ -432,16 +505,41 @@ def render_diagram(
if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target):
return _result()
# 1b) Mermaid local: system browser → Playwright Chromium → QuickJS
if kroki_type == "mermaid":
try:
from .mermaid_renderer import write_mermaid_outputs
if write_mermaid_outputs(
prepared, out_png, svg_target, scale=scale,
):
return _result(pscale=scale if scale > 1 else 1.0)
except Exception as exc:
_log.warning("Mermaid local render failed: %s", exc)
# 2) Local Kroki
if _render_kroki_pair(prepared, kroki_type, local_kroki, out_png, svg_target):
return _result()
if _render_kroki_pair(prepared, kroki_type, local_kroki, kroki_png, kroki_svg_target):
if kroki_type == "mermaid":
return _result(kroki_png, kroki_svg_path, pscale=1.0)
return _result(kroki_png, kroki_svg_path)
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,
)
for candidate in (out_png, kroki_png):
if candidate.is_file() and candidate.stat().st_size > 0:
_log.warning("Диаграмма: SVG недоступен, вставляю только PNG")
pscale = pixel_scale
if (
kroki_type == "mermaid"
and candidate == out_png
and scale > 1
and local_prefix != cache_prefix
):
pscale = scale
elif kroki_type == "mermaid" and candidate == kroki_png:
pscale = 1.0
return DiagramRenderResult(
png_path=str(candidate), svg_path=None, pixel_scale=pscale,
)
raise RuntimeError(message)
if mode == "off":
@@ -451,8 +549,8 @@ def render_diagram(
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
)
return _png_fallback_or_raise(
"Не удалось отрендерить диаграмму через локальный Kroki "
"(задайте --kroki-url / KROKI_URL; remote fallback отключён)"
"Не удалось отрендерить диаграмму локально "
"(браузер / QuickJS / --kroki-url; remote fallback отключён)"
)
if mode == "local":
if kroki_type == "plantuml":
@@ -461,8 +559,8 @@ def render_diagram(
"(Java+plantuml.jar или локальный Kroki)"
)
return _png_fallback_or_raise(
"Локальный Kroki недоступен для диаграммы "
f"(тип {kroki_type}; задайте работающий --kroki-url)"
"Локальный рендер Mermaid недоступен "
f"(браузер / Chromium / QuickJS / Kroki; тип {kroki_type})"
)
# 3) Remote fallback
@@ -470,11 +568,13 @@ def render_diagram(
"Диаграмма: локальный рендер недоступен, использую remote %s",
REMOTE_KROKI_URL,
)
if _render_kroki_pair(prepared, kroki_type, REMOTE_KROKI_URL, out_png, svg_target):
return _result()
if _render_kroki_pair(prepared, kroki_type, REMOTE_KROKI_URL, kroki_png, kroki_svg_target):
if kroki_type == "mermaid":
return _result(kroki_png, kroki_svg_path, pscale=1.0)
return _result(kroki_png, kroki_svg_path)
return _png_fallback_or_raise(
"Не удалось отрендерить диаграмму (PlantUML/Kroki)"
"Не удалось отрендерить диаграмму (PlantUML/Mermaid/Kroki)"
)
+2314
View File
File diff suppressed because one or more lines are too long
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>md2gost mermaid</title>
<script src="mermaid.min.js"></script>
<style>
html, body { margin: 0; padding: 0; background: #fff; }
#out { display: inline-block; }
</style>
</head>
<body>
<div id="out"></div>
<script>
// Browser: htmlLabels:true — корректные боксы под кириллицу; PNG = screenshot.
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
flowchart: {
htmlLabels: true,
wrappingWidth: 320,
subGraphTitleMargin: { top: 12, bottom: 16 },
padding: 20,
},
themeVariables: {
fontFamily: '"Segoe UI", "DejaVu Sans", Arial, sans-serif',
},
});
/** Put subgraph titles above node rows and paint them on top (Mermaid LR quirk). */
function fixClusterLabels(root) {
root.querySelectorAll("g.cluster").forEach((cluster) => {
const label = cluster.querySelector(".cluster-label");
const rect = cluster.querySelector(":scope > rect, rect");
if (!label || !rect) return;
const fo = label.querySelector("foreignObject");
const rw = parseFloat(rect.getAttribute("width") || "0");
const rx = parseFloat(rect.getAttribute("x") || "0");
const ry = parseFloat(rect.getAttribute("y") || "0");
if (fo && rw > 0) {
const maxW = Math.max(160, rw - 24);
fo.setAttribute("width", String(maxW));
const inner = fo.querySelector("div");
if (inner) {
inner.style.maxWidth = maxW + "px";
inner.style.width = maxW + "px";
inner.style.whiteSpace = "normal";
}
}
let labelH = 28;
try {
const bb = label.getBBox();
if (bb && bb.height > 0) labelH = bb.height;
else if (fo) labelH = parseFloat(fo.getAttribute("height") || "48") || 48;
} catch (e) {
if (fo) labelH = parseFloat(fo.getAttribute("height") || "48") || 48;
}
let labelW = 200;
try {
const bb = label.getBBox();
if (bb && bb.width > 0) labelW = bb.width;
else if (fo) labelW = parseFloat(fo.getAttribute("width") || "200") || 200;
} catch (e) {
if (fo) labelW = parseFloat(fo.getAttribute("width") || "200") || 200;
}
const x = rx + Math.max(0, (rw - labelW) / 2);
const y = ry + 6;
label.setAttribute("transform", "translate(" + x + ", " + y + ")");
cluster.appendChild(label);
});
}
window.__md2gostRender = async function (code) {
const id = "m" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const out = await mermaid.render(id, code);
const box = document.getElementById("out");
box.innerHTML = out.svg;
fixClusterLabels(box);
const svg = box.querySelector("svg");
return svg ? svg.outerHTML : out.svg;
};
</script>
</body>
</html>
+92 -10
View File
@@ -65,7 +65,7 @@ TABLE_LABELS = {
}
FALLBACK_LABELS = {
"remote": "Авто: локально, иначе интернет (kroki.io)",
"local": "Только этот компьютер (нужна Java)",
"local": "Только этот компьютер (без kroki.io)",
"off": "Не рисовать диаграммы",
}
FORMAT_LABELS = {
@@ -257,6 +257,7 @@ class Md2GostApp:
self.plantuml_var = tk.StringVar()
self.kroki_var = tk.StringVar()
self.scale_var = tk.StringVar(value="2")
self.page_start_var = tk.StringVar(value="")
self.highlight_var = tk.BooleanVar(value=False)
self.debug_var = tk.BooleanVar(value=False)
self.output_var.trace_add("write", self._on_output_edited)
@@ -351,8 +352,8 @@ class Md2GostApp:
win = tk.Toplevel(self.root)
win.title("Настройки — Диаграммы")
win.configure(bg=PAPER)
win.minsize(560, 420)
win.geometry("680x520")
win.minsize(560, 480)
win.geometry("700x600")
win.transient(self.root)
extra = ttk.Frame(win, padding=12)
extra.pack(fill=tk.BOTH, expand=True)
@@ -363,7 +364,8 @@ class Md2GostApp:
text=(
"Блоки ```uml / ```uml-c4 / ```bpmn / ```mermaid сами станут рисунками.\n"
"Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n"
"UML: сначала PlantUML + Java, иначе интернет (kroki.io). Mermaid — только через Kroki."
"UML: PlantUML + Java, иначе Kroki. Mermaid: системный браузер → "
"Chromium → QuickJS → Kroki (kroki.io остаётся запасным)."
),
style="Hint.TLabel",
wraplength=640,
@@ -375,16 +377,25 @@ class Md2GostApp:
status_row.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))
status_row.columnconfigure(0, weight=1)
self.diag_status = ttk.Label(status_row, text="", style="Hint.TLabel", wraplength=480, justify=tk.LEFT)
self.diag_status.grid(row=0, column=0, sticky="w")
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=2)
btn_dl = ttk.Button(status_row, text="Скачать PlantUML", command=self._download_plantuml)
btn_dl.grid(row=0, column=1, sticky="e", padx=(8, 0))
_tip(btn_dl, "Скачать plantuml.jar для локальной отрисовки (нужна Java)")
btn_cr = ttk.Button(
status_row, text="Скачать headless Chromium", command=self._download_chromium,
)
btn_cr.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
_tip(
btn_cr,
"Скачать Chromium для Mermaid в %LOCALAPPDATA%\\md2gost "
"(если нет системного Edge/Chrome)",
)
self._combo(
extra, 2, "Как рисовать",
self.fallback_var,
_combo_values(FALLBACK_LABELS, ("remote", "local", "off")),
tip="Локально (PlantUML+Java), через интернет или не рисовать",
tip="Локально (PlantUML / браузер / QuickJS), через интернет или не рисовать",
)
self._combo(
extra, 3, "Формат схем",
@@ -393,12 +404,12 @@ class Md2GostApp:
tip="PNG везде; SVG — вектор в Word 2016+ (PNG рядом как запасной)",
)
ttk.Label(extra, text="Scale PNG (PlantUML)").grid(row=4, column=0, sticky="w", padx=(0, 8), pady=3)
ttk.Label(extra, text="Scale PNG").grid(row=4, column=0, sticky="w", padx=(0, 8), pady=3)
scale_row = ttk.Frame(extra)
scale_row.grid(row=4, column=1, sticky="w", pady=3)
scale_entry = ttk.Entry(scale_row, textvariable=self.scale_var, width=8)
scale_entry.pack(side=tk.LEFT)
_tip(scale_entry, "Качество растра PlantUML; на странице размер как при 1")
_tip(scale_entry, "Качество растра PlantUML / локального Mermaid; на странице размер как при 1")
ttk.Label(
scale_row,
text=" качество; на странице размер как при 1",
@@ -422,7 +433,10 @@ class Md2GostApp:
tip="Пусто — вшитый jar или скачанный автоматически")
ttk.Label(
extra,
text="Свой Kroki — только если у вас крутится свой сервер. Пусто = сначала localhost:8000, потом kroki.io.",
text=(
"Свой Kroki — запасной путь (и для UML без Java). Пусто = localhost:8000, потом kroki.io. "
"Mermaid сначала рисуется локально (браузер / QuickJS)."
),
style="Hint.TLabel",
wraplength=640,
).grid(row=8, column=0, columnspan=2, sticky="w", pady=(6, 0))
@@ -822,8 +836,24 @@ class Md2GostApp:
self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES),
tip="То же для листингов; word требует Windows + Word + pywin32")
ttk.Label(main, text="Смещение страниц").grid(row=5, column=0, sticky="w", padx=(0, 8), pady=3)
page_row = ttk.Frame(main)
page_row.grid(row=5, column=1, sticky="w", pady=3)
page_entry = ttk.Entry(page_row, textvariable=self.page_start_var, width=8)
page_entry.pack(side=tk.LEFT)
_tip(
page_entry,
"Начальный номер PAGE в колонтитуле тела. Пусто — сквозная нумерация Word; "
"например 1 — тело с 1 (титул/задание без номера)",
)
ttk.Label(
page_row,
text=" пусто = сквозной; 1 = тело с первой",
style="Hint.TLabel",
).pack(side=tk.LEFT)
flags = ttk.Frame(main)
flags.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0))
flags.grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
checks = [
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
@@ -1260,6 +1290,45 @@ class Md2GostApp:
"Можно ничего не делать — диаграммы нарисует kroki.io.",
)
def _download_chromium(self) -> None:
from .mermaid_renderer import install_playwright_chromium, playwright_browsers_dir, playwright_available
if not playwright_available():
messagebox.showerror(
"md2gost",
"Пакет playwright не установлен.\n"
"Установите: pip install playwright\n"
"Mermaid всё равно может рисоваться через QuickJS (mermaidx) или Kroki.",
)
return
dest = playwright_browsers_dir()
self._append_log(f"Скачиваю headless Chromium → {dest}")
self.root.update_idletasks()
def work() -> None:
ok = install_playwright_chromium()
self.root.after(0, lambda: self._on_chromium_installed(ok, dest))
import threading
threading.Thread(target=work, daemon=True).start()
def _on_chromium_installed(self, ok: bool, dest) -> None:
self._refresh_diagram_status()
if ok:
messagebox.showinfo(
"md2gost",
f"Chromium установлен:\n{dest}\n\n"
"Будет использоваться для локального Mermaid, если нет системного Edge/Chrome.",
)
else:
messagebox.showerror(
"md2gost",
"Не удалось скачать Chromium.\n"
"Попробуйте: md2gost --install-chromium\n"
"Или оставьте как есть — Mermaid через QuickJS / kroki.io.",
)
def _pick_into(self, var: tk.StringVar, title: str, types) -> None:
path = filedialog.askopenfilename(title=title, filetypes=types)
if path:
@@ -1300,6 +1369,8 @@ class Md2GostApp:
self.strict_var.set(bool(req.strict))
self.check_pages_var.set(bool(getattr(req, "check_pages", False)))
self.table_repeat_header_var.set(bool(getattr(req, "table_repeat_header", False)))
start = getattr(req, "page_number_start", None)
self.page_start_var.set("" if start is None else str(int(start)))
self.highlight_var.set(bool(req.syntax_highlighting))
self.debug_var.set(bool(req.debug))
self._debug_next = bool(req.debug)
@@ -1333,6 +1404,7 @@ class Md2GostApp:
check_only=self.check_only_var.get(),
check_pages=self.check_pages_var.get(),
table_repeat_header=self.table_repeat_header_var.get(),
page_number_start=self._parse_page_start(),
strict=self.strict_var.get(),
syntax_highlighting=self.highlight_var.get(),
plantuml_jar=self.plantuml_var.get().strip() or None,
@@ -1345,6 +1417,16 @@ class Md2GostApp:
open_when_done=self.open_var.get(),
)
def _parse_page_start(self) -> int | None:
raw = (self.page_start_var.get() or "").strip()
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value >= 1 else None
def _parse_diagram_scale(self) -> float:
raw = (self.scale_var.get() or "2").strip().replace(",", ".")
try:
+9 -7
View File
@@ -13,7 +13,7 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
1. Перетащите .md в верхнюю область (или кликните по ней).
2. Выберите тип документа и параметры в блоке «Основные».
Шаблон / титул / задание Настройки Файлы.
PlantUML / Kroki Настройки Диаграммы.
PlantUML / Mermaid / Kroki Настройки Диаграммы.
Свои UML-схемы меню «Шаблоны UML».
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
Если файл уже есть и вы откажетесь перезаписывать сохранится как имя_гггг-мм-дд-ЧЧ-ММ.docx.
@@ -24,6 +24,7 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
Полезные галочки
«» «-» заменить типографское тире на дефис (по умолчанию **выкл.**; методичка требует «»).
--- разрыв страницы по умолчанию выкл. (строка --- игнорируется). Вкл. page break в Word.
Смещение страниц начальный номер PAGE в теле (пусто = сквозной; 1 = тело с 1).
Проверить по ТЗ замечания по структуре, «рис.», источникам.
Проверить вёрстку в Word полупустые страницы (эвристика, возможны ложные срабатывания; нужны Word + pywin32).
Титул / задание отдельные DOCX, вставляются перед телом отчёта.
@@ -93,7 +94,7 @@ CLI (тот же движок)
```
Mermaid (через Kroki, свой URL или kroki.io):
Mermaid (локально: браузер / QuickJS; иначе Kroki):
%seq Последовательность +listing
```mermaid
sequenceDiagram
@@ -104,7 +105,7 @@ CLI (тот же движок)
+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+).
Формат в Word: PNG по умолчанию (PlantUML и локальный Mermaid ~2× для чёткости); --diagram-format svg вектор + PNG-запасной (Word 2016+).
IDEF0 конвертер не рисует вставляйте готовый PNG как обычный Рисунок.
Подробнее Справка Схемы и раздел ниже в инструкции.
@@ -114,7 +115,7 @@ IDEF0 конвертер не рисует — вставляйте готовы
См. @Формула:eq1
Источники
В тексте: [1]
В тексте: [1] (в DOCX ссылка на пункт списка)
В списке: [1]: Иванов И. И. Название. М.: Наука, 2023. 120 с.
Разрыв страницы
@@ -157,7 +158,7 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
```uml-c4 схема с id «c4» (то же, что ```c4)
```uml-usecase схема «usecase»
```bpmn / ```uml-bpmn BPMN 2.0 (макросы в diagrams/BPMN.puml)
```mermaid / ```mmd Mermaid через Kroki (не PlantUML)
```mermaid / ```mmd Mermaid локально (браузер / Chromium / QuickJS) или через Kroki
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase, bpmn.
Пример
@@ -225,8 +226,9 @@ BPMN 2.0 (```bpmn)
Рендер
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+).
Mermaid: системный Chrome/Edge Playwright Chromium (кнопка / --install-chromium)
QuickJS (mermaidx, офлайн) локальный Kroki kroki.io.
Формат: PNG по умолчанию (PlantUML / локальный Mermaid ~scale 2 для чёткости, размер на странице как при 1; --diagram-scale); --diagram-format svg PNG + SVG (svgBlip в Word 2016+).
+landscape у %подписи альбомная страница под широкий рисунок/таблицу.
"""
+607
View File
@@ -0,0 +1,607 @@
"""Local Mermaid rendering: system browser → Playwright Chromium → QuickJS (mermaidx)."""
from __future__ import annotations
import logging
import os
import subprocess
import threading
from pathlib import Path
from typing import Literal
from . import package_dir
_log = logging.getLogger(__name__)
# Pinned assets next to PlantUML schemes.
MERMAID_JS_NAME = "mermaid.min.js"
MERMAID_PAGE_NAME = "mermaid_page.html"
CACHE_KEY_PREFIX = "mmd-local-v4\n"
# QuickJS/resvg cannot paint HTML foreignObject — use SVG <text> there only.
# Browser keeps htmlLabels:true (correct box sizing for Cyrillic) + PNG via screenshot.
_MERMAID_CONFIG_QUICKJS = {
"flowchart": {"htmlLabels": False},
"themeVariables": {
"fontFamily": '"Segoe UI", "DejaVu Sans", Arial, sans-serif',
},
}
_MERMAID_INIT_QUICKJS = (
"%%{init: {"
"'flowchart': {'htmlLabels': false}, "
"'themeVariables': {'fontFamily': '\"Segoe UI\", \"DejaVu Sans\", Arial, sans-serif'}"
"}}%%\n"
)
_MERMAID_INIT_BROWSER = (
"%%{init: {"
"'flowchart': {"
"'htmlLabels': true, "
"'wrappingWidth': 320, "
"'subGraphTitleMargin': {'top': 12, 'bottom': 16}, "
"'padding': 20"
"}, "
"'themeVariables': {'fontFamily': '\"Segoe UI\", \"DejaVu Sans\", Arial, sans-serif'}"
"}}%%\n"
)
def prepare_mermaid_source(source: str, *, html_labels: bool = True) -> str:
"""
Prepend %%{init}%%.
html_labels=True browser (correct layout for Cyrillic, PNG via screenshot).
html_labels=False QuickJS/resvg (SVG <text> so glyphs are not dropped).
"""
text = source.lstrip("\ufeff").strip()
if text.startswith("%%{init"):
return text
return (_MERMAID_INIT_BROWSER if html_labels else _MERMAID_INIT_QUICKJS) + text
# Prefer installed browsers; bundled Chromium is optional (button / --install-chromium).
_SYSTEM_CHANNELS = ("msedge", "chrome", "chromium")
_lock = threading.RLock()
_session: "_BrowserSession | None" = None
_UNSET = object()
_cached_system_channel: str | None | object = _UNSET
def diagrams_dir() -> Path:
return Path(package_dir()) / "diagrams"
def _windows_browser_exe(channel: str) -> Path | None:
"""Best-effort path probe so GUI status does not launch browsers."""
local = Path(os.environ.get("LOCALAPPDATA") or "")
pf = Path(os.environ.get("PROGRAMFILES") or r"C:\Program Files")
pf86 = Path(os.environ.get("PROGRAMFILES(X86)") or r"C:\Program Files (x86)")
candidates: list[Path] = []
if channel == "msedge":
candidates = [
pf / "Microsoft" / "Edge" / "Application" / "msedge.exe",
pf86 / "Microsoft" / "Edge" / "Application" / "msedge.exe",
]
elif channel == "chrome":
candidates = [
pf / "Google" / "Chrome" / "Application" / "chrome.exe",
pf86 / "Google" / "Chrome" / "Application" / "chrome.exe",
local / "Google" / "Chrome" / "Application" / "chrome.exe",
]
elif channel == "chromium":
candidates = [
pf / "Chromium" / "Application" / "chrome.exe",
local / "Chromium" / "Application" / "chrome.exe",
]
for path in candidates:
if path.is_file():
return path
return None
def mermaid_assets_dir() -> Path:
return diagrams_dir()
def mermaid_page_path() -> Path:
return mermaid_assets_dir() / MERMAID_PAGE_NAME
def mermaid_js_path() -> Path:
return mermaid_assets_dir() / MERMAID_JS_NAME
def user_data_root() -> Path:
if os.name == "nt":
return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
return Path.home() / ".md2gost"
def playwright_browsers_dir() -> Path:
env = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
if env and env not in ("0", "1"):
return Path(env)
return user_data_root() / "ms-playwright"
def ensure_playwright_browsers_env() -> Path:
"""Point Playwright at %LOCALAPPDATA%/md2gost/ms-playwright (or ~/.md2gost)."""
root = playwright_browsers_dir()
root.mkdir(parents=True, exist_ok=True)
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(root)
return root
def mermaidx_available() -> bool:
try:
import mermaidx # noqa: F401
return True
except ImportError:
return False
def playwright_available() -> bool:
try:
from playwright.sync_api import sync_playwright # noqa: F401
return True
except ImportError:
return False
def _playwright_driver() -> tuple[str, str] | None:
try:
from playwright._impl._driver import compute_driver_executable
node, cli = compute_driver_executable()
return str(node), str(cli)
except Exception as exc:
_log.debug("Playwright driver: %s", exc)
return None
def chromium_installed() -> bool:
"""True if Playwright Chromium binary exists under PLAYWRIGHT_BROWSERS_PATH."""
if not playwright_available():
return False
ensure_playwright_browsers_env()
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
exe = Path(p.chromium.executable_path)
return exe.is_file()
except Exception:
return False
def detect_system_browser_channel(*, probe_launch: bool = False) -> str | None:
"""
Return first usable Playwright channel (msedge / chrome / chromium).
By default only checks known install paths (fast, for GUI status).
Set probe_launch=True to actually launch (used once before first render).
"""
global _cached_system_channel
if _cached_system_channel is not _UNSET and not probe_launch:
return _cached_system_channel # type: ignore[return-value]
if not playwright_available():
_cached_system_channel = None
return None
# Path probe first (Windows); on other OS fall through to launch probe.
if os.name == "nt":
for channel in _SYSTEM_CHANNELS:
if _windows_browser_exe(channel) is not None:
_cached_system_channel = channel
return channel
if not probe_launch:
_cached_system_channel = None
return None
if not probe_launch and os.name == "nt":
return None
try:
from playwright.sync_api import sync_playwright
except ImportError:
_cached_system_channel = None
return None
ensure_playwright_browsers_env()
for channel in _SYSTEM_CHANNELS:
try:
with sync_playwright() as p:
browser = p.chromium.launch(channel=channel, headless=True)
browser.close()
_cached_system_channel = channel
return channel
except Exception as exc:
_log.debug("channel %s unavailable: %s", channel, exc)
_cached_system_channel = None
return None
def install_playwright_chromium() -> bool:
"""Download Playwright Chromium into user cache. Safe for frozen exe."""
if not playwright_available():
_log.warning("playwright не установлен — нельзя скачать Chromium")
return False
driver = _playwright_driver()
if not driver:
_log.warning("Playwright driver не найден")
return False
node, cli = driver
root = ensure_playwright_browsers_env()
env = os.environ.copy()
env["PLAYWRIGHT_BROWSERS_PATH"] = str(root)
cmd = [node, cli, "install", "chromium"]
_log.info("Установка Playwright Chromium → %s", root)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
check=False,
env=env,
)
except (OSError, subprocess.TimeoutExpired) as exc:
_log.warning("install chromium failed: %s", exc)
return False
if proc.returncode != 0:
_log.warning(
"playwright install chromium → %s\n%s\n%s",
proc.returncode,
proc.stdout,
proc.stderr,
)
return False
ok = chromium_installed()
if not ok:
_log.warning("Chromium скачан, но executable_path не найден")
return ok
def mermaid_engine_status() -> str:
"""Human-readable Mermaid status for the GUI."""
lines: list[str] = []
if not playwright_available():
lines.append("Mermaid browser: playwright не установлен")
else:
ch = detect_system_browser_channel()
if ch:
lines.append(f"Mermaid browser: системный ({ch})")
else:
lines.append("Mermaid browser: системный Chrome/Edge не найден")
if chromium_installed():
lines.append(f"Mermaid Chromium: есть ({playwright_browsers_dir()})")
else:
lines.append(
"Mermaid Chromium: нет. Кнопка «Скачать headless Chromium» "
"или md2gost --install-chromium"
)
if mermaidx_available():
lines.append("Mermaid QuickJS (mermaidx): готов (офлайн без браузера)")
else:
lines.append("Mermaid QuickJS: mermaidx не установлен")
page = mermaid_page_path()
js = mermaid_js_path()
if page.is_file() and js.is_file():
lines.append(f"Mermaid assets: {js.name} + {page.name}")
else:
lines.append("Mermaid assets: нет mermaid.min.js / mermaid_page.html")
return "\n".join(lines)
def svg_to_png_bytes(svg: str, *, scale: float = 1.0) -> bytes | None:
try:
from mermaidx import svg_to_png
except ImportError:
return None
try:
if scale and scale > 1:
return _svg_to_png_scaled(svg, float(scale))
return svg_to_png(svg)
except Exception as exc:
_log.warning("svg→png failed: %s", exc)
return None
def _svg_to_png_scaled(svg: str, scale: float) -> bytes:
from mermaidx import svg_to_png
if scale <= 1:
return svg_to_png(svg)
# Parse width="N" or width="Npx" from root svg; multiply for sharper raster.
import re
m = re.search(r"<svg\b[^>]*\bwidth=['\"]([\d.]+)(px)?['\"]", svg, re.I)
if m:
w = float(m.group(1)) * scale
return svg_to_png(svg, width=w)
# Fallback: resvg default size, then Pillow upsample (last resort)
png = svg_to_png(svg)
try:
from io import BytesIO
from PIL import Image
im = Image.open(BytesIO(png))
im = im.resize(
(max(1, int(im.width * scale)), max(1, int(im.height * scale))),
Image.Resampling.LANCZOS,
)
buf = BytesIO()
im.save(buf, format="PNG")
return buf.getvalue()
except Exception:
return png
def _render_quickjs(source: str, *, scale: float = 1.0) -> tuple[str, bytes] | None:
if not mermaidx_available():
return None
try:
from mermaidx import render
except ImportError:
return None
try:
prepared = prepare_mermaid_source(source, html_labels=False)
diagram = render(prepared, backend="quickjs", config=_MERMAID_CONFIG_QUICKJS)
svg = diagram.svg()
if not isinstance(svg, str) or "<svg" not in svg.lower():
return None
scale_kw = {"scale": float(scale)} if scale and scale > 1 else {}
png = diagram.png(**scale_kw)
if not isinstance(png, (bytes, bytearray)) or not png.startswith(b"\x89PNG"):
png_b = svg_to_png_bytes(svg, scale=scale)
if not png_b:
return None
png = png_b
return svg, bytes(png)
except Exception as exc:
_log.warning("mermaidx QuickJS failed: %s", exc)
return None
class _BrowserSession:
"""One Playwright browser for the whole conversion (system channel or bundled Chromium)."""
def __init__(self, *, channel: str | None, use_bundled: bool) -> None:
from playwright.sync_api import sync_playwright
ensure_playwright_browsers_env()
self._pw_cm = sync_playwright()
self._pw = self._pw_cm.__enter__()
kwargs: dict = {"headless": True}
if channel:
kwargs["channel"] = channel
elif not use_bundled:
raise RuntimeError("no browser channel")
self._browser = self._pw.chromium.launch(**kwargs)
# device_scale_factor set per render via new context when needed; default 1.
self._context = self._browser.new_context(device_scale_factor=1)
self._page = self._context.new_page()
page_file = mermaid_page_path()
js_file = mermaid_js_path()
if not page_file.is_file() or not js_file.is_file():
raise FileNotFoundError(f"Mermaid assets missing under {mermaid_assets_dir()}")
self._page.goto(page_file.resolve().as_uri(), wait_until="load")
self._page.wait_for_function("typeof window.__md2gostRender === 'function'")
self._scale = 1.0
def _ensure_scale(self, scale: float) -> None:
"""Recreate context if DPR must change for sharper screenshots."""
want = max(1.0, float(scale) if scale else 1.0)
if abs(want - self._scale) < 0.01:
return
self._context.close()
self._context = self._browser.new_context(device_scale_factor=want)
self._page = self._context.new_page()
self._page.goto(mermaid_page_path().resolve().as_uri(), wait_until="load")
self._page.wait_for_function("typeof window.__md2gostRender === 'function'")
self._scale = want
def render_svg(self, source: str) -> str:
prepared = prepare_mermaid_source(source, html_labels=True)
svg = self._page.evaluate("(code) => window.__md2gostRender(code)", prepared)
if not isinstance(svg, str) or "<svg" not in svg.lower():
raise RuntimeError("browser mermaid.render returned no SVG")
return svg
def render_png(self, source: str, *, scale: float = 1.0) -> tuple[str, bytes]:
"""
Browser: htmlLabels + screenshot at viewBox size (layout + Cyrillic + hi-res).
"""
self._ensure_scale(scale)
prepared = prepare_mermaid_source(source, html_labels=True)
svg = self._page.evaluate(
"(code) => window.__md2gostRender(code)",
prepared,
)
if not isinstance(svg, str) or "<svg" not in svg.lower():
raise RuntimeError("browser mermaid.render returned no SVG")
# Mermaid sets width="100%" — force viewBox pixel size before capture.
dims = self._page.evaluate(
"""() => {
const el = document.querySelector('#out svg');
if (!el) return null;
let w = 0, h = 0;
try {
const vb = el.viewBox && el.viewBox.baseVal;
if (vb && vb.width > 0 && vb.height > 0) {
w = vb.width;
h = vb.height;
}
} catch (e) {}
if (!(w > 0 && h > 0)) {
try {
const b = el.getBBox();
w = b.width;
h = b.height;
} catch (e) {}
}
if (!(w > 0 && h > 0)) return null;
el.setAttribute('width', String(w));
el.setAttribute('height', String(h));
el.style.width = w + 'px';
el.style.height = h + 'px';
el.style.maxWidth = 'none';
el.style.maxHeight = 'none';
return { w: Math.ceil(w), h: Math.ceil(h) };
}"""
)
if isinstance(dims, dict):
vw = max(1280, int(dims.get("w") or 0) + 64)
vh = max(720, int(dims.get("h") or 0) + 64)
self._page.set_viewport_size({"width": vw, "height": vh})
# Prefer screenshot: FO/HTML labels render correctly in a real browser.
png: bytes | None = None
loc = self._page.locator("#out svg").first
loc.wait_for(state="visible", timeout=15000)
shot = loc.screenshot(type="png", omit_background=False)
if isinstance(shot, (bytes, bytearray)) and shot.startswith(b"\x89PNG"):
png = bytes(shot)
if png is None and "foreignObject" not in svg:
png = svg_to_png_bytes(svg, scale=scale)
if not png:
raise RuntimeError("no PNG from browser path")
return svg, png
def close(self) -> None:
try:
self._browser.close()
except Exception:
pass
try:
self._pw_cm.__exit__(None, None, None)
except Exception:
pass
def reset_mermaid_session() -> None:
global _session
with _lock:
if _session is not None:
try:
_session.close()
except Exception:
pass
_session = None
def _get_session(*, prefer_system: bool = True) -> _BrowserSession | None:
global _session
with _lock:
if _session is not None:
return _session
if not playwright_available():
return None
page = mermaid_page_path()
if not page.is_file() or not mermaid_js_path().is_file():
return None
# 1) system browser
if prefer_system:
ch = detect_system_browser_channel(probe_launch=True)
if ch:
try:
_session = _BrowserSession(channel=ch, use_bundled=False)
_log.info("Mermaid: системный браузер (%s)", ch)
return _session
except Exception as exc:
_log.warning("Mermaid system browser failed: %s", exc)
_session = None
# Retry other channels once
for alt in _SYSTEM_CHANNELS:
if alt == ch:
continue
try:
_session = _BrowserSession(channel=alt, use_bundled=False)
_log.info("Mermaid: системный браузер (%s)", alt)
return _session
except Exception:
_session = None
# 2) Playwright Chromium
if chromium_installed():
try:
_session = _BrowserSession(channel=None, use_bundled=True)
_log.info("Mermaid: Playwright Chromium")
return _session
except Exception as exc:
_log.warning("Mermaid Chromium failed: %s", exc)
_session = None
return None
def _render_browser(source: str, *, scale: float = 1.0) -> tuple[str, bytes] | None:
session = _get_session(prefer_system=True)
if session is None:
return None
try:
return session.render_png(source, scale=scale)
except Exception as exc:
_log.warning("Mermaid browser render failed: %s", exc)
reset_mermaid_session()
return None
BackendName = Literal["browser", "quickjs"]
def render_mermaid_local(
source: str,
*,
scale: float = 1.0,
) -> tuple[str, bytes, BackendName] | None:
"""
Try local Mermaid backends in order: system/Chromium browser QuickJS.
Returns (svg, png_bytes, backend) or None.
"""
got = _render_browser(source, scale=scale)
if got:
return got[0], got[1], "browser"
got = _render_quickjs(source, scale=scale)
if got:
return got[0], got[1], "quickjs"
return None
def write_mermaid_outputs(
source: str,
out_png: Path,
out_svg: Path | None,
*,
scale: float = 1.0,
) -> bool:
"""Render locally and write PNG (+ optional SVG)."""
result = render_mermaid_local(source, scale=scale)
if not result:
return False
svg, png, backend = result
out_png.write_bytes(png)
if out_svg is not None:
out_svg.write_text(svg, encoding="utf-8")
_log.info("Mermaid local ok (%s) → %s", backend, out_png.name)
return True
__all__ = [
"CACHE_KEY_PREFIX",
"chromium_installed",
"detect_system_browser_channel",
"ensure_playwright_browsers_env",
"install_playwright_chromium",
"mermaid_engine_status",
"mermaidx_available",
"playwright_available",
"playwright_browsers_dir",
"prepare_mermaid_source",
"render_mermaid_local",
"reset_mermaid_session",
"write_mermaid_outputs",
]
+35
View File
@@ -156,3 +156,38 @@ def ensure_continuous_page_numbers(document) -> None:
# Keep continuous: remove start attribute if present
if child.get(qn("w:start")) is not None:
del child.attrib[qn("w:start")]
def set_section_page_start(section, start: int) -> None:
"""Set w:pgNumType/@w:start so PAGE field begins at ``start`` in this section."""
if start < 1:
return
sect_pr = section._sectPr
pg = sect_pr.find(qn("w:pgNumType"))
if pg is None:
sect_pr.append(create_element("w:pgNumType", {"w:start": str(int(start))}))
else:
pg.set(qn("w:start"), str(int(start)))
def apply_page_number_start(
document,
start: int | None,
*,
front_sections: int = 0,
) -> None:
"""
Apply page-number offset for the body.
``start`` is None / <1 leave continuous numbering (strip explicit starts).
``start`` >= 1 first section after front matter restarts PAGE at that number;
later body sections stay continuous from there.
"""
ensure_continuous_page_numbers(document)
if start is None or int(start) < 1:
return
sections = list(document.sections)
idx = max(0, int(front_sections))
if idx >= len(sections):
return
set_section_page_start(sections[idx], int(start))
+23
View File
@@ -56,6 +56,8 @@ class ConvertRequest:
open_when_done: bool = False
check_pages: bool = False
table_repeat_header: bool = False
# PAGE field start on body section; None = continuous from document start.
page_number_start: int | None = None
@dataclass
@@ -121,6 +123,20 @@ def _fix_front_matter_after_compose(document, *, had_title: bool, had_assignment
ensure_continuous_page_numbers(document)
def _apply_page_offset(document, req: ConvertRequest, *, had_title: bool, had_assignment: bool) -> None:
from .page_geometry import apply_page_number_start
start = getattr(req, "page_number_start", None)
if start is None:
return
try:
start_i = int(start)
except (TypeError, ValueError):
return
front = int(bool(had_title)) + int(bool(had_assignment))
apply_page_number_start(document, start_i, front_sections=front)
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
@@ -255,6 +271,13 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
had_assignment=bool(req.assignment),
)
_apply_page_offset(
document,
req,
had_title=bool(req.title),
had_assignment=bool(req.assignment),
)
document.core_properties.author = getuser()
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
try:
+109 -2
View File
@@ -1,10 +1,9 @@
from copy import copy
from typing import Generator, Any
from typing import Generator
from docx.shared import Length, Parented, RGBColor
from docx.text.paragraph import Paragraph as DocxParagraph
from docx.text.paragraph import Run as DocxRun
from docx.enum.text import WD_LINE_SPACING
from docx.opc.constants import RELATIONSHIP_TYPE
from . import Renderable
@@ -17,6 +16,14 @@ from ..util import create_element
from ..rendered_info import RenderedInfo
from ..latex_math import latex_to_omml, inline_omml
_bookmark_seq = 0
def _next_bookmark_id() -> int:
global _bookmark_seq
_bookmark_seq += 1
return _bookmark_seq
class Link:
def __init__(self, url, docx_paragraph: DocxParagraph):
@@ -48,6 +55,39 @@ class Link:
return self._hyperlink
class InternalLink:
"""Hyperlink to a bookmark in the same document (w:anchor)."""
def __init__(self, anchor: str, docx_paragraph: DocxParagraph):
self._docx_paragraph = docx_paragraph
self._hyperlink = create_element("w:hyperlink", {"w:anchor": anchor})
def add_run(
self,
text: str,
is_bold: bool = None,
is_italic: bool = None,
color: RGBColor = None,
strike_through: bool = None,
):
docx_run = DocxRun(create_element("w:r"), self._docx_paragraph)
self._hyperlink.append(docx_run._element)
docx_run.text = text
try:
docx_run.style = "Hyperlink"
except KeyError:
pass
docx_run.bold = is_bold
docx_run.italic = is_italic
if color is not None:
docx_run.font.color.rgb = color
docx_run.font.strike = strike_through
@property
def element(self):
return self._hyperlink
class Paragraph(Renderable):
def __init__(self, parent: Parented):
self._parent = parent
@@ -70,6 +110,73 @@ class Paragraph(Renderable):
self._docx_paragraph.add_run()._element.\
append(create_element("w:noBreakHyphen"))
def add_run_with_citations(
self,
text: str,
is_bold: bool = None,
is_italic: bool = None,
color: RGBColor = None,
strike_through: bool = None,
):
"""Split body text so [1] / [2, 3] become internal links to bibliography bookmarks."""
import re
from ..bibliography import CITE_RE
kw = dict(
is_bold=is_bold, is_italic=is_italic, color=color, strike_through=strike_through,
)
pos = 0
for m in CITE_RE.finditer(text):
if m.start() > pos:
self.add_run(text[pos:m.start()], **kw)
full = m.group(0)
inner = m.group(1)
self.add_run("[", **kw)
for token in re.split(r"(\s*,\s*)", inner):
if re.fullmatch(r"\d+(?:\.\d+)?", (token or "").strip()):
self.add_biblio_link(token.strip(), token, **kw)
elif token:
self.add_run(token, **kw)
suffix = full[1 + len(inner):-1]
if suffix:
self.add_run(suffix, **kw)
self.add_run("]", **kw)
pos = m.end()
if pos < len(text):
self.add_run(text[pos:], **kw)
def add_biblio_link(
self,
key: str,
text: str | None = None,
is_bold: bool = None,
is_italic: bool = None,
color: RGBColor = None,
strike_through: bool = None,
):
from ..bibliography import biblio_bookmark_name
link = InternalLink(biblio_bookmark_name(key), self._docx_paragraph)
link.add_run(
text if text is not None else key,
is_bold=is_bold,
is_italic=is_italic,
color=color,
strike_through=strike_through,
)
self._docx_paragraph._p.append(link.element)
return link
def wrap_with_bookmark(self, name: str) -> None:
"""Surround current paragraph content with a Word bookmark."""
bid = str(_next_bookmark_id())
p = self._docx_paragraph._p
start = create_element("w:bookmarkStart", {"w:id": bid, "w:name": name})
end = create_element("w:bookmarkEnd", {"w:id": bid})
p.insert(0, start)
p.append(end)
def add_reference(self, type_: str, name: str):
"""Placeholder run resolved later via resolve_pending_refs()."""
run = self._docx_paragraph.add_run(f"{type_}?")
+17 -4
View File
@@ -37,10 +37,23 @@ class RenderableFactory:
classes = []
for child in children:
if isinstance(child, (extended_markdown.RawText, extended_markdown.Literal)):
paragraph_or_link.add_run(child.children,
is_bold=extended_markdown.StrongEmphasis in classes or None,
is_italic=extended_markdown.Emphasis in classes or None,
strike_through=extended_markdown.Strikethrough in classes or None)
text = child.children
if isinstance(paragraph_or_link, Paragraph) and hasattr(
paragraph_or_link, "add_run_with_citations"
):
paragraph_or_link.add_run_with_citations(
text,
is_bold=extended_markdown.StrongEmphasis in classes or None,
is_italic=extended_markdown.Emphasis in classes or None,
strike_through=extended_markdown.Strikethrough in classes or None,
)
else:
paragraph_or_link.add_run(
text,
is_bold=extended_markdown.StrongEmphasis in classes or None,
is_italic=extended_markdown.Emphasis in classes or None,
strike_through=extended_markdown.Strikethrough in classes or None,
)
elif isinstance(child, extended_markdown.CodeSpan):
paragraph_or_link.add_run(child.children, is_italic=True)
elif isinstance(child, extended_markdown.Image):