- add Local Render Mermaid - add page-starе для указания смещения страниц - add Гиперссылки в документе на списки литературы
This commit is contained in:
+11
-4
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Vendored
+2314
File diff suppressed because one or more lines are too long
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 у %подписи — альбомная страница под широкий рисунок/таблицу.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_}?")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user