v0.5.3
- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Binary file not shown.
+1
-1
@@ -7,7 +7,7 @@
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
uv sync # окружение по uv.lock (канонический lock-файл)
|
||||
# или
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
@@ -26,6 +26,7 @@ SPECIAL_UNNUMBERED = {
|
||||
}
|
||||
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(\*?)(.+)$", re.M)
|
||||
EMPTY_HEADING_RE = re.compile(r"^(#{1,6})[^\n\S]*\*?[^\n\S]*$", re.M)
|
||||
FOOTNOTE_RE = re.compile(r"\[\^[^\]]+\]")
|
||||
RIS_RE = re.compile(r"(?i)\bрис\.")
|
||||
TABL_RE = re.compile(r"(?i)\bтабл\.")
|
||||
@@ -199,6 +200,19 @@ def check_special_headings(text: str) -> list[Issue]:
|
||||
return issues
|
||||
|
||||
|
||||
def check_empty_headings(text: str) -> list[Issue]:
|
||||
"""Заголовок без текста: пронумерованный, но безымянный раздел в содержании."""
|
||||
issues = []
|
||||
for m in EMPTY_HEADING_RE.finditer(text):
|
||||
issues.append(Issue(
|
||||
"heading.empty", "warning",
|
||||
"Заголовок без текста: разделы, подразделы и пункты должны иметь "
|
||||
"заголовки, чётко и кратко отражающие их содержание",
|
||||
_line_of(text, m.start()),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_forbidden_abbreviations(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in RIS_RE.finditer(text):
|
||||
@@ -399,6 +413,8 @@ def check_continuation_hints(
|
||||
*,
|
||||
table_continuation: str = "off",
|
||||
listing_continuation: str = "off",
|
||||
syntax_highlighting: bool = False,
|
||||
strict: bool = False,
|
||||
) -> list[Issue]:
|
||||
"""Warn that Word cannot auto-insert «Продолжение…» when mode is off/soft."""
|
||||
issues: list[Issue] = []
|
||||
@@ -422,6 +438,13 @@ def check_continuation_hints(
|
||||
"Длинный листинг: Word сам не вставит «Продолжение Листинга N». "
|
||||
"Варианты: --listing-continuation word или caption.",
|
||||
))
|
||||
if syntax_highlighting:
|
||||
issues.append(Issue(
|
||||
"listing.syntax_highlighting",
|
||||
"error" if strict else "warning",
|
||||
"Подсветка синтаксиса задаёт полужирный и цвет в содержимом листинга. "
|
||||
"Табл. 4.1 методички требует «Начертание Обычный», «Цвет текста Авто (чёрный)».",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
@@ -490,11 +513,14 @@ def check_markdown(
|
||||
*,
|
||||
table_continuation: str = "off",
|
||||
listing_continuation: str = "off",
|
||||
syntax_highlighting: bool = False,
|
||||
strict: bool = False,
|
||||
) -> list[Issue]:
|
||||
profile = get_profile(doc_type)
|
||||
issues: list[Issue] = []
|
||||
issues.extend(check_structure(text, profile))
|
||||
issues.extend(check_special_headings(text))
|
||||
issues.extend(check_empty_headings(text))
|
||||
issues.extend(check_pis_headings(text, profile))
|
||||
issues.extend(check_forbidden_abbreviations(text))
|
||||
if profile.require_intro_conclusion:
|
||||
@@ -507,6 +533,8 @@ def check_markdown(
|
||||
text,
|
||||
table_continuation=table_continuation,
|
||||
listing_continuation=listing_continuation,
|
||||
syntax_highlighting=syntax_highlighting,
|
||||
strict=strict,
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ from .renderable.diagram import DiagramFigure
|
||||
class Converter:
|
||||
"""Converts markdown file to docx file (MIREA TZ)."""
|
||||
|
||||
def __init__(self, input_path: str, output_path: str,
|
||||
def __init__(self, input_path: str,
|
||||
template_path: str = None, debug: bool = False,
|
||||
doc_type: str = "practice",
|
||||
heading_numbering: str = DEFAULT_HEADING_NUMBERING,
|
||||
@@ -67,7 +67,6 @@ class Converter:
|
||||
f"listing_continuation must be one of {LISTING_CONTINUATION_MODES}, "
|
||||
f"got {listing_continuation!r}"
|
||||
)
|
||||
self._output_path = output_path
|
||||
self._doc_type = doc_type
|
||||
self._heading_numbering = heading_numbering
|
||||
self._emdash_to_hyphen = emdash_to_hyphen
|
||||
|
||||
+45
-3
@@ -400,6 +400,43 @@ def _render_dot_local(dot: str, out_path: Path, fmt: str, *, context: bool) -> b
|
||||
return out_path.is_file() and out_path.stat().st_size > 0
|
||||
|
||||
|
||||
def _render_dot_local_combined(
|
||||
dot: str,
|
||||
targets: list[tuple[Path, str]],
|
||||
*,
|
||||
context: bool,
|
||||
) -> set[str]:
|
||||
"""
|
||||
One Graphviz process for several formats (`-Tpng -o… -Tsvg -o…`).
|
||||
Returns the formats actually written; the caller retries the rest one by one.
|
||||
"""
|
||||
if len(targets) < 2:
|
||||
return set()
|
||||
engine = resolve_graphviz_engine(context=context)
|
||||
if not engine:
|
||||
return set()
|
||||
cmd = [engine]
|
||||
for out_path, fmt in targets:
|
||||
cmd += [f"-T{fmt}", f"-o{out_path}"]
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
input=_readable_dot(dot),
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=180,
|
||||
env=_graphviz_run_env(engine),
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
_log.warning("Graphviz %s (multi-format) failed: %s", engine, exc)
|
||||
return set()
|
||||
return {
|
||||
fmt for out_path, fmt in targets
|
||||
if Path(out_path).is_file() and Path(out_path).stat().st_size > 0
|
||||
}
|
||||
|
||||
|
||||
def _render_dot_kroki(dot: str, out_path: Path, fmt: str) -> bool:
|
||||
try:
|
||||
from .diagram_renderer import (
|
||||
@@ -455,8 +492,14 @@ def write_dfd_outputs(
|
||||
dot, is_context = _compile_dot(source)
|
||||
out_png = Path(out_png)
|
||||
out_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_svg = Path(out_svg) if out_svg is not None else None
|
||||
|
||||
ok_png = _render_dot_local(dot, out_png, "png", context=is_context)
|
||||
targets: list[tuple[Path, str]] = [(out_png, "png")]
|
||||
if out_svg is not None:
|
||||
targets.append((out_svg, "svg"))
|
||||
done = _render_dot_local_combined(dot, targets, context=is_context)
|
||||
|
||||
ok_png = "png" in done or _render_dot_local(dot, out_png, "png", context=is_context)
|
||||
if not ok_png:
|
||||
ok_png = _render_dot_kroki(dot, out_png, "png")
|
||||
if not ok_png:
|
||||
@@ -467,8 +510,7 @@ def write_dfd_outputs(
|
||||
"DSL: https://github.com/pbauermeister/dfd"
|
||||
)
|
||||
|
||||
if out_svg is not None:
|
||||
out_svg = Path(out_svg)
|
||||
if out_svg is not None and "svg" not in done:
|
||||
ok_svg = _render_dot_local(dot, out_svg, "svg", context=is_context)
|
||||
if not ok_svg:
|
||||
_render_dot_kroki(dot, out_svg, "svg")
|
||||
|
||||
+208
-30
@@ -60,6 +60,11 @@ PLANTUML_JAR_URL = (
|
||||
f"https://github.com/plantuml/plantuml/releases/download/"
|
||||
f"v{PLANTUML_VERSION}/plantuml-{PLANTUML_VERSION}.jar"
|
||||
)
|
||||
# Part of the cache key: a new jar draws the same source differently.
|
||||
PLANTUML_CACHE_PREFIX = f"plantuml-{PLANTUML_VERSION}\n"
|
||||
|
||||
# One JVM per chunk; keeps the command line far below the Windows 32k limit.
|
||||
PLANTUML_BATCH_CHUNK = 60
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,6 +104,7 @@ def configure_diagrams(
|
||||
global _CONFIG
|
||||
ensure_user_schemes()
|
||||
configure_schemes(schemes_path=schemes_path, md_dir=md_dir)
|
||||
reset_diagram_batch()
|
||||
try:
|
||||
from .mermaid_renderer import ensure_playwright_browsers_env, reset_mermaid_session
|
||||
|
||||
@@ -298,17 +304,45 @@ def _is_svg(data: bytes) -> bool:
|
||||
return head.startswith(b"<svg") or head.startswith(b"<?xml") or b"<svg" in head
|
||||
|
||||
|
||||
def _render_plantuml_jar_one(
|
||||
source: str,
|
||||
out_path: Path,
|
||||
@dataclass
|
||||
class _PumlJob:
|
||||
"""One PlantUML source and where its rendered file must land."""
|
||||
|
||||
source: str
|
||||
out_path: Path
|
||||
|
||||
|
||||
def _pick_produced(out_dir: Path, fmt: str) -> Path | None:
|
||||
"""Output name follows `@startuml <name>`, so take what the jar actually wrote."""
|
||||
if not out_dir.is_dir():
|
||||
return None
|
||||
files = [
|
||||
p for p in out_dir.iterdir()
|
||||
if p.is_file() and p.suffix.lower() == f".{fmt}"
|
||||
]
|
||||
if not files:
|
||||
return None
|
||||
# `newpage` adds name_001, name_002…; the shortest name is the first page.
|
||||
files.sort(key=lambda p: (len(p.name), p.name))
|
||||
return files[0]
|
||||
|
||||
|
||||
def _plantuml_run_chunk(
|
||||
chunk: list[_PumlJob],
|
||||
jar: str,
|
||||
fmt: Literal["png", "svg"],
|
||||
) -> bool:
|
||||
if not jar or not os.path.isfile(jar) or not _java_available():
|
||||
return False
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "diagram.puml"
|
||||
src.write_text(source, encoding="utf-8")
|
||||
done: list[bool],
|
||||
offset: int,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="md2gost-puml-") as tmp:
|
||||
root = Path(tmp)
|
||||
sources: list[str] = []
|
||||
for i, job in enumerate(chunk):
|
||||
sub = root / f"{i:04d}"
|
||||
sub.mkdir()
|
||||
src = sub / "src.puml"
|
||||
src.write_text(job.source, encoding="utf-8")
|
||||
sources.append(str(src))
|
||||
cmd = [
|
||||
"java",
|
||||
f"-Dplantuml.include.path={diagrams_dir().resolve()}",
|
||||
@@ -316,27 +350,63 @@ def _render_plantuml_jar_one(
|
||||
"-jar", jar,
|
||||
f"-t{fmt}",
|
||||
"-charset", "UTF-8",
|
||||
"-o", tmp,
|
||||
str(src),
|
||||
# Relative -o resolves against each input's own folder, so two sources
|
||||
# naming themselves `@startuml Name` cannot collide on one output file.
|
||||
"-o", "out",
|
||||
*sources,
|
||||
]
|
||||
timeout = min(600, 120 + 30 * (len(chunk) - 1))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120, check=False,
|
||||
cmd, capture_output=True, text=True, timeout=timeout, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
_log.warning("PlantUML jar failed: %s", e)
|
||||
return False
|
||||
produced = Path(tmp) / f"diagram.{fmt}"
|
||||
if proc.returncode != 0 or not produced.is_file():
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
|
||||
return False
|
||||
data = produced.read_bytes()
|
||||
if fmt == "png" and not _is_png(data):
|
||||
return False
|
||||
if fmt == "svg" and not _is_svg(data):
|
||||
return False
|
||||
shutil.copyfile(produced, out_path)
|
||||
return True
|
||||
return
|
||||
for i, job in enumerate(chunk):
|
||||
produced = _pick_produced(root / f"{i:04d}" / "out", fmt)
|
||||
if produced is None:
|
||||
_log.warning("PlantUML jar: %s не создан", fmt)
|
||||
continue
|
||||
data = produced.read_bytes()
|
||||
if fmt == "png" and not _is_png(data):
|
||||
continue
|
||||
if fmt == "svg" and not _is_svg(data):
|
||||
continue
|
||||
job.out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(produced, job.out_path)
|
||||
done[offset + i] = True
|
||||
|
||||
|
||||
def _plantuml_run(
|
||||
jobs: list[_PumlJob],
|
||||
jar: str,
|
||||
fmt: Literal["png", "svg"],
|
||||
) -> list[bool]:
|
||||
"""Render every job in one JVM per chunk. Returns per-job success flags."""
|
||||
if not jobs:
|
||||
return []
|
||||
if not jar or not os.path.isfile(jar) or not _java_available():
|
||||
return [False] * len(jobs)
|
||||
done = [False] * len(jobs)
|
||||
for start in range(0, len(jobs), PLANTUML_BATCH_CHUNK):
|
||||
_plantuml_run_chunk(
|
||||
jobs[start:start + PLANTUML_BATCH_CHUNK], jar, fmt, done, start,
|
||||
)
|
||||
return done
|
||||
|
||||
|
||||
def _render_plantuml_jar_one(
|
||||
source: str,
|
||||
out_path: Path,
|
||||
jar: str,
|
||||
fmt: Literal["png", "svg"],
|
||||
) -> bool:
|
||||
done = _plantuml_run([_PumlJob(source, Path(out_path))], jar, fmt)
|
||||
return bool(done and done[0])
|
||||
|
||||
|
||||
def _render_plantuml_jar(
|
||||
@@ -355,6 +425,100 @@ def _render_plantuml_jar(
|
||||
return True
|
||||
|
||||
|
||||
def _plantuml_scaled(prepared: str, scale: float) -> tuple[str, float]:
|
||||
"""Return (source, pixel_scale); pixel_scale stays 1 when the author set scale."""
|
||||
if scale <= 1:
|
||||
return prepared, 1.0
|
||||
boosted = _ensure_plantuml_png_scale(prepared, scale)
|
||||
return boosted, (scale if boosted != prepared else 1.0)
|
||||
|
||||
|
||||
# Fences seen while parsing, so the whole document can share one JVM.
|
||||
_PENDING: list[tuple[str, str]] = []
|
||||
_PENDING_SEEN: set[tuple[str, str]] = set()
|
||||
_BATCH_FLUSHED = False
|
||||
|
||||
|
||||
def register_pending_diagram(lang: str, source: str) -> None:
|
||||
"""Announce a diagram fence found during parsing (see _flush_plantuml_batch)."""
|
||||
key = (lang or "", source or "")
|
||||
if key in _PENDING_SEEN:
|
||||
return
|
||||
_PENDING_SEEN.add(key)
|
||||
_PENDING.append(key)
|
||||
|
||||
|
||||
def reset_diagram_batch() -> None:
|
||||
global _BATCH_FLUSHED
|
||||
_PENDING.clear()
|
||||
_PENDING_SEEN.clear()
|
||||
_BATCH_FLUSHED = False
|
||||
|
||||
|
||||
def _flush_plantuml_batch() -> None:
|
||||
"""
|
||||
Pre-pass before the first diagram renders: draw every cache-missing PlantUML
|
||||
source of the document in one JVM instead of one (two in svg mode) per diagram.
|
||||
Anything that fails here simply stays a cache miss and takes the normal path.
|
||||
"""
|
||||
global _BATCH_FLUSHED
|
||||
if _BATCH_FLUSHED:
|
||||
return
|
||||
_BATCH_FLUSHED = True
|
||||
pending = list(_PENDING)
|
||||
_PENDING.clear()
|
||||
_PENDING_SEEN.clear()
|
||||
if len(pending) < 2:
|
||||
return
|
||||
jar = resolve_plantuml_jar(_CONFIG.plantuml_jar or None, download=False)
|
||||
if not jar or not _java_available():
|
||||
return
|
||||
|
||||
want_svg = _CONFIG.diagram_format == "svg"
|
||||
scale = _CONFIG.diagram_scale
|
||||
png_jobs: list[_PumlJob] = []
|
||||
svg_jobs: list[_PumlJob] = []
|
||||
seen: set[str] = set()
|
||||
for lang, source in pending:
|
||||
try:
|
||||
prepared, kroki_type = prepare_source(lang, source)
|
||||
except Exception:
|
||||
continue
|
||||
if kroki_type != "plantuml":
|
||||
continue
|
||||
prepared, _ = _plantuml_scaled(prepared, scale)
|
||||
out_png, out_svg = _cache_paths(prepared, cache_prefix=PLANTUML_CACHE_PREFIX)
|
||||
if str(out_png) in seen:
|
||||
continue
|
||||
seen.add(str(out_png))
|
||||
if not (out_png.is_file() and out_png.stat().st_size > 0):
|
||||
png_jobs.append(_PumlJob(prepared, out_png))
|
||||
if want_svg and not (out_svg.is_file() and out_svg.stat().st_size > 0):
|
||||
svg_jobs.append(_PumlJob(prepared, out_svg))
|
||||
|
||||
if not png_jobs and not svg_jobs:
|
||||
return
|
||||
_log.info(
|
||||
"PlantUML: пакетный рендер %d диаграмм одной JVM",
|
||||
max(len(png_jobs), len(svg_jobs)),
|
||||
)
|
||||
if png_jobs:
|
||||
_plantuml_run(png_jobs, jar, "png")
|
||||
if svg_jobs:
|
||||
_plantuml_run(svg_jobs, jar, "svg")
|
||||
|
||||
|
||||
def shutdown_diagrams() -> None:
|
||||
"""Release per-conversion resources: headless browser and the batch queue."""
|
||||
reset_diagram_batch()
|
||||
try:
|
||||
from .mermaid_renderer import reset_mermaid_session
|
||||
|
||||
reset_mermaid_session()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _render_kroki(
|
||||
source: str,
|
||||
diagram_type: str,
|
||||
@@ -414,22 +578,32 @@ def render_diagram(
|
||||
diagram_scale: float | None = None,
|
||||
) -> DiagramRenderResult:
|
||||
"""Render diagram to PNG (+ optional SVG). Raises RuntimeError if all backends fail."""
|
||||
ensure_user_schemes()
|
||||
# Batch only the plain document path; explicit overrides would not match _CONFIG.
|
||||
if not any(
|
||||
arg is not None
|
||||
for arg in (
|
||||
plantuml_jar, kroki_url, fallback, cache_dir, diagram_format, diagram_scale,
|
||||
)
|
||||
):
|
||||
try:
|
||||
_flush_plantuml_batch()
|
||||
except Exception as exc:
|
||||
_log.warning("PlantUML: пакетный рендер не удался (%s)", exc)
|
||||
|
||||
prepared, kroki_type = prepare_source(lang, source)
|
||||
scale = _CONFIG.diagram_scale if diagram_scale is None else float(diagram_scale)
|
||||
if scale < 1:
|
||||
scale = 1.0
|
||||
# pixel_scale: only when we injected scale (author override → treat as 1 for display).
|
||||
pixel_scale = 1.0
|
||||
if kroki_type == "plantuml" and scale > 1:
|
||||
before = prepared
|
||||
prepared = _ensure_plantuml_png_scale(prepared, scale)
|
||||
if prepared != before:
|
||||
pixel_scale = scale
|
||||
if kroki_type == "plantuml":
|
||||
prepared, pixel_scale = _plantuml_scaled(prepared, scale)
|
||||
# Mermaid: pixel_scale set only after successful local hi-res render (not Kroki).
|
||||
|
||||
cache_prefix = ""
|
||||
if kroki_type == "mermaid":
|
||||
if kroki_type == "plantuml":
|
||||
cache_prefix = PLANTUML_CACHE_PREFIX
|
||||
elif kroki_type == "mermaid":
|
||||
try:
|
||||
from .mermaid_renderer import CACHE_KEY_PREFIX
|
||||
|
||||
@@ -633,8 +807,12 @@ __all__ = [
|
||||
"fetch_plantuml_jar",
|
||||
"is_diagram_lang",
|
||||
"prepare_source",
|
||||
"register_pending_diagram",
|
||||
"render_diagram",
|
||||
"reset_diagram_batch",
|
||||
"resolve_plantuml_jar",
|
||||
"shutdown_diagrams",
|
||||
"PLANTUML_CACHE_PREFIX",
|
||||
"PLANTUML_JAR_URL",
|
||||
"PLANTUML_VERSION",
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ class Heading(BlockElement):
|
||||
def __init__(self, match: Match[str]) -> None:
|
||||
self.level = len(match.group(1))
|
||||
inline_body = match.group(2).strip()
|
||||
self.numbered = not (inline_body[0] == "*")
|
||||
self.numbered = not inline_body.startswith("*")
|
||||
if not self.numbered:
|
||||
inline_body = inline_body[1:]
|
||||
self.inline_body = inline_body
|
||||
|
||||
@@ -2018,32 +2018,6 @@ def _draw_arrow(
|
||||
return paths
|
||||
|
||||
|
||||
def _draw_purpose(scene: _Scene, d: Idef0Diagram, work: _Rect, s: float, fonts: dict) -> _Rect | None:
|
||||
"""Kept for callers; kit form does not put Purpose/Viewpoint in the work area."""
|
||||
bits = []
|
||||
if d.purpose:
|
||||
bits.append(f"Purpose: {d.purpose}")
|
||||
if d.viewpoint:
|
||||
bits.append(f"Viewpoint: {d.viewpoint}")
|
||||
if not bits:
|
||||
return None
|
||||
font = _load_font(int(fonts["small"]), False)
|
||||
lines: list[str] = []
|
||||
for text in bits:
|
||||
lines.extend(_wrap(text, font, int(work.w * 0.40)))
|
||||
if not lines:
|
||||
return None
|
||||
pad = 3 * s
|
||||
tw, th = _label_block_size(lines, font, fonts["small"], pad)
|
||||
halo = _Rect(work.x + 4 * s, work.b - th - 4 * s, tw, th)
|
||||
scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None)
|
||||
line_h = fonts["small"] * 1.15
|
||||
y0 = halo.y + pad + fonts["small"] * 0.2
|
||||
for i, ln in enumerate(lines):
|
||||
scene.text(halo.x + pad, y0 + i * line_h, ln, fonts["small"], anchor="lt")
|
||||
return halo.inflate(4 * s)
|
||||
|
||||
|
||||
def render_idef0(
|
||||
diagram: Idef0Diagram,
|
||||
*,
|
||||
|
||||
@@ -102,9 +102,6 @@ def assign_numbers(renderables: list[Renderable],
|
||||
r.set_listing_number(lnum)
|
||||
if label:
|
||||
registry.put("Листинг", label, lnum)
|
||||
# Images nested in paragraphs
|
||||
if isinstance(r, Paragraph):
|
||||
pass
|
||||
|
||||
# Images attached to paragraphs
|
||||
if isinstance(r, Paragraph):
|
||||
|
||||
+13
-4
@@ -8,15 +8,24 @@ from lxml.etree import _Element
|
||||
|
||||
from . import package_dir
|
||||
|
||||
_MML2OMML_TRANSFORM: etree.XSLT | None = None
|
||||
|
||||
|
||||
def _mml2omml_transform() -> etree.XSLT:
|
||||
"""The stylesheet is stateless and reusable, so parse and compile it once."""
|
||||
global _MML2OMML_TRANSFORM
|
||||
if _MML2OMML_TRANSFORM is None:
|
||||
_MML2OMML_TRANSFORM = etree.XSLT(etree.parse(
|
||||
os.path.join(package_dir(), "mml2omml")
|
||||
))
|
||||
return _MML2OMML_TRANSFORM
|
||||
|
||||
|
||||
def latex_to_omml(latex_equation: str) -> _Element:
|
||||
try:
|
||||
mathml = latex2mathml.converter.convert(latex_equation)
|
||||
tree = etree.fromstring(mathml)
|
||||
xslt = etree.parse(
|
||||
os.path.join(package_dir(), "mml2omml")
|
||||
)
|
||||
transform = etree.XSLT(xslt)
|
||||
transform = _mml2omml_transform()
|
||||
new_dom = transform(tree)
|
||||
word_math = new_dom.getroot()
|
||||
except Exception:
|
||||
|
||||
@@ -404,13 +404,6 @@ class _BrowserSession:
|
||||
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).
|
||||
|
||||
+10
-1
@@ -294,6 +294,8 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
req.doc_type,
|
||||
table_continuation=req.table_continuation,
|
||||
listing_continuation=req.listing_continuation,
|
||||
syntax_highlighting=req.syntax_highlighting,
|
||||
strict=req.strict,
|
||||
)
|
||||
check_report = format_report(issues)
|
||||
emit(check_report)
|
||||
@@ -319,7 +321,7 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
|
||||
try:
|
||||
converter = Converter(
|
||||
filename, output, template, req.debug,
|
||||
filename, template, req.debug,
|
||||
doc_type=req.doc_type,
|
||||
heading_numbering=req.heading_numbering,
|
||||
emdash_to_hyphen=req.emdash_to_hyphen,
|
||||
@@ -404,6 +406,13 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
message=f"Ошибка конвертации: {exc}",
|
||||
)
|
||||
finally:
|
||||
# Headless Chrome would otherwise stay alive until the next conversion.
|
||||
try:
|
||||
from .diagram_renderer import shutdown_diagrams
|
||||
|
||||
shutdown_diagrams()
|
||||
except Exception:
|
||||
pass
|
||||
root_log.removeHandler(handler)
|
||||
attached_diag.removeHandler(handler)
|
||||
attached_diag.setLevel(prev_diag_level)
|
||||
|
||||
@@ -9,7 +9,7 @@ from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from md2gost.layout_tracker import LayoutState
|
||||
from md2gost.renderable import Renderable
|
||||
from md2gost.rendered_info import RenderedInfo
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from .paragraph_sizer import ParagraphSizerResult, measure_paragraph
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
@@ -35,8 +35,16 @@ class Caption(Renderable):
|
||||
self._parent = parent
|
||||
self._before = before
|
||||
self._category = category
|
||||
self._size_cache: dict = {}
|
||||
self._docx_paragraph = DocxParagraph(create_element("w:p"), parent)
|
||||
|
||||
# Table/listing captions use keep_with_next. pageBreakBefore + keepWithNext
|
||||
# on a paragraph that Word already wraps to a new page produces a blank
|
||||
# page that the layout tracker / debug overlay never sees. Written here
|
||||
# rather than at the end of render() so that measuring the caption twice
|
||||
# (fit check, then emit) sees the same paragraph.
|
||||
self._docx_paragraph.paragraph_format.page_break_before = False
|
||||
|
||||
style_name = CAPTION_STYLES.get(category, "Caption")
|
||||
try:
|
||||
self._docx_paragraph.style = style_name
|
||||
@@ -58,18 +66,20 @@ class Caption(Renderable):
|
||||
def center(self):
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
def measure(self, previous_paragraph: DocxParagraph | None, max_width) -> ParagraphSizerResult:
|
||||
"""Line count and spacing of the caption line against a given predecessor."""
|
||||
return measure_paragraph(
|
||||
self._size_cache, self._docx_paragraph, previous_paragraph, max_width
|
||||
)
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||||
"RenderedInfo | Renderable", None, None]:
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
height_data = self.measure(
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width
|
||||
).calculate_height()
|
||||
)
|
||||
|
||||
# Table/listing captions use keep_with_next. pageBreakBefore + keepWithNext
|
||||
# on a paragraph that Word already wraps to a new page produces a blank
|
||||
# page that the layout tracker / debug overlay never sees.
|
||||
leftover = 0
|
||||
need_new_page = (
|
||||
self._before
|
||||
@@ -79,11 +89,6 @@ class Caption(Renderable):
|
||||
)
|
||||
if need_new_page:
|
||||
leftover = layout_state.remaining_page_height
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
None,
|
||||
layout_state.max_width
|
||||
).calculate_height()
|
||||
height_data = self.measure(None, layout_state.max_width)
|
||||
|
||||
self._docx_paragraph.paragraph_format.page_break_before = False
|
||||
yield RenderedInfo(self._docx_paragraph, height_data.full + leftover)
|
||||
|
||||
@@ -8,7 +8,7 @@ from .image import Image
|
||||
from .listing import Listing
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..diagram_renderer import render_diagram
|
||||
from ..diagram_renderer import register_pending_diagram, render_diagram
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
|
||||
@@ -34,6 +34,9 @@ class DiagramFigure(Renderable, RequiresNumbering):
|
||||
)
|
||||
self._number = None
|
||||
self._listing_number = None
|
||||
# Parsing runs fully before rendering, so this collects the whole document
|
||||
# and lets the first render() draw every PlantUML source in one JVM.
|
||||
register_pending_diagram(lang, source)
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
@@ -79,6 +79,7 @@ class Image(Renderable, RequiresNumbering):
|
||||
logging.warning("SVG blip attach failed (%s): %s", svg_path, exc)
|
||||
|
||||
self._number = None
|
||||
self._caption: Caption | None = None
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
@@ -89,6 +90,21 @@ class Image(Renderable, RequiresNumbering):
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
self._caption = None
|
||||
|
||||
def _get_caption(self) -> Caption:
|
||||
"""One caption object per figure.
|
||||
|
||||
Paragraph.render() lays a figure out once to learn its height and the
|
||||
Renderer then renders it for real; a shared caption keeps the second
|
||||
pass from re-measuring the same line.
|
||||
"""
|
||||
if self._caption is None:
|
||||
self._caption = Caption(
|
||||
self._parent, "Рисунок", self._caption_info, self._number, False
|
||||
)
|
||||
self._caption.center()
|
||||
return self._caption
|
||||
|
||||
def _reset_native_size(self) -> None:
|
||||
if self._image is None or self._native_width is None or self._native_height is None:
|
||||
@@ -142,7 +158,4 @@ class Image(Renderable, RequiresNumbering):
|
||||
|
||||
layout_state.add_height(rendered_image.height)
|
||||
|
||||
caption = Caption(self._parent, "Рисунок", self._caption_info, self._number, False)
|
||||
caption.center()
|
||||
|
||||
yield from caption.render(rendered_image, copy(layout_state))
|
||||
yield from self._get_caption().render(rendered_image, copy(layout_state))
|
||||
|
||||
@@ -33,7 +33,7 @@ class DocxParagraphPygmentsFormatter(Formatter):
|
||||
self._styles[token] = style
|
||||
|
||||
def _add_run_to_last_paragraph(self, text, style):
|
||||
self._paragraphs[-1].add_run(text, style["bold"] or None, style["italic"] in style or None,
|
||||
self._paragraphs[-1].add_run(text, style["bold"] or None, style.get("italic") or None,
|
||||
RGBColor.from_string(style['color']) if style['color'] else None)
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
@@ -63,20 +63,6 @@ class Listing(Renderable, RequiresNumbering):
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
def _create_table(self, parent, width: Length):
|
||||
# Kept for tests / callers that still expect the helper; render uses multi-row.
|
||||
left_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
right_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
|
||||
return create_table(
|
||||
parent, 1, 1,
|
||||
Twips(_twips(width) + _twips(left_margin) + _twips(right_margin)),
|
||||
)
|
||||
|
||||
def set_text(self, text: str):
|
||||
def create_paragraph() -> Paragraph:
|
||||
paragraph = Paragraph(self._parent)
|
||||
|
||||
@@ -9,7 +9,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE
|
||||
from . import Renderable
|
||||
from .caption import CaptionInfo
|
||||
from .image import Image
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from .paragraph_sizer import ParagraphSizerResult, measure_paragraph
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
@@ -41,7 +41,7 @@ class Link:
|
||||
for i, part in enumerate(parts):
|
||||
docx_run = DocxRun(create_element("w:r"), self._docx_paragraph)
|
||||
self._hyperlink.append(docx_run._element)
|
||||
docx_run.text = text
|
||||
docx_run.text = part
|
||||
docx_run.style = "Hyperlink"
|
||||
docx_run.bold = is_bold
|
||||
docx_run.italic = is_italic
|
||||
@@ -95,6 +95,7 @@ class Paragraph(Renderable):
|
||||
self._docx_paragraph.style = "Normal"
|
||||
self._images: list[Image] = []
|
||||
self._pending_refs: list[tuple[object, str, str]] = [] # (run, type, name)
|
||||
self._size_cache: dict = {}
|
||||
|
||||
def add_run(self, text: str, is_bold: bool = None, is_italic: bool = None, color: RGBColor = None,
|
||||
strike_through: bool = None):
|
||||
@@ -231,6 +232,13 @@ class Paragraph(Renderable):
|
||||
def first_line_indent(self, value: Length):
|
||||
self._docx_paragraph.paragraph_format.first_line_indent = value
|
||||
|
||||
def measure(self, previous_paragraph: DocxParagraph | None,
|
||||
max_width: Length) -> ParagraphSizerResult:
|
||||
"""Line count and spacing of this paragraph against a given predecessor."""
|
||||
return measure_paragraph(
|
||||
self._size_cache, self._docx_paragraph, previous_paragraph, max_width
|
||||
)
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
remaining_space = layout_state.remaining_page_height
|
||||
@@ -238,11 +246,10 @@ class Paragraph(Renderable):
|
||||
if self.page_break_before:
|
||||
layout_state.add_height(layout_state.remaining_page_height)
|
||||
if self._docx_paragraph.text or not self._images:
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
height_data = self.measure(
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width).calculate_height()
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width)
|
||||
|
||||
if layout_state.current_page_height == 0 and layout_state.page > 1:
|
||||
height_data.before = 0
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import cached_property, lru_cache
|
||||
from math import ceil
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from docx.enum.text import WD_LINE_SPACING
|
||||
from docx.oxml import CT_R
|
||||
from docx.text.run import Run
|
||||
@@ -70,7 +72,7 @@ class Font:
|
||||
bbox = self._draw.textbbox((0, 0), text, self._freetypefont)
|
||||
return Pt(bbox[2] - bbox[0])
|
||||
else:
|
||||
return Pt(len(text) * self._face.glyph.advance.x / 64)
|
||||
return Pt(len(text) * self._advance / 64)
|
||||
|
||||
def get_line_height(self) -> Length:
|
||||
# TODO: make it work for all fonts
|
||||
@@ -80,13 +82,36 @@ class Font:
|
||||
return Pt(13.61)
|
||||
else:
|
||||
return Pt(self._face.size.height / 64)
|
||||
|
||||
@cached_property
|
||||
def is_mono(self):
|
||||
def _metrics(self) -> tuple[bool, int]:
|
||||
# The advance is snapshotted here instead of being read back from
|
||||
# self._face.glyph later: the face is shared between callers via
|
||||
# _get_font, and any other load_char would otherwise change the glyph
|
||||
# slot under us.
|
||||
self._face.load_char("i")
|
||||
i_width = self._face.glyph.advance.x
|
||||
self._face.load_char("m")
|
||||
return i_width == self._face.glyph.advance.x
|
||||
# return self._face.glyph.bitmap.width
|
||||
m_width = self._face.glyph.advance.x
|
||||
return i_width == m_width, m_width
|
||||
|
||||
@property
|
||||
def is_mono(self) -> bool:
|
||||
return self._metrics[0]
|
||||
|
||||
@property
|
||||
def _advance(self) -> int:
|
||||
return self._metrics[1]
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _get_font(name: str, bold: bool, italic: bool, size_pt: float) -> Font:
|
||||
"""Fonts are immutable once built, so identical descriptors share one instance.
|
||||
|
||||
Constructing a Font costs ~2.6 ms (FreeType face + PIL canvas) and
|
||||
calculate_height() would otherwise build 2 + len(runs) of them per call.
|
||||
"""
|
||||
return Font(name, bold, italic, size_pt)
|
||||
|
||||
|
||||
|
||||
@@ -152,7 +177,7 @@ class ParagraphSizer:
|
||||
lines = 1
|
||||
line_width = first_line_indent
|
||||
|
||||
space_width = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
|
||||
space_width = _get_font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
|
||||
if not is_mono:
|
||||
space_width *= 0.81
|
||||
|
||||
@@ -168,7 +193,7 @@ class ParagraphSizer:
|
||||
docx_font,
|
||||
run.font
|
||||
)
|
||||
font = Font(run_docx_font.name, run_docx_font.bold, run_docx_font.italic, run_docx_font.size.pt)
|
||||
font = _get_font(run_docx_font.name, run_docx_font.bold, run_docx_font.italic, run_docx_font.size.pt)
|
||||
|
||||
run_text = run.text
|
||||
if run_text == "" and run._element.xpath("w:noBreakHyphen"):
|
||||
@@ -178,6 +203,11 @@ class ParagraphSizer:
|
||||
|
||||
for c in run_text:
|
||||
if c == " ":
|
||||
# word_parts_widths[-1] is only ever read here and at the run
|
||||
# boundary below, so the prefix width is measured once per
|
||||
# word instead of once per character.
|
||||
if word_part:
|
||||
word_parts_widths[-1] = font.get_text_width(word_part)
|
||||
if any(word_parts_widths):
|
||||
width = spaces*space_width + sum(word_parts_widths)
|
||||
if width <= max_width - line_width:
|
||||
@@ -200,7 +230,11 @@ class ParagraphSizer:
|
||||
spaces += 1
|
||||
else:
|
||||
word_part += c
|
||||
word_parts_widths[-1] = font.get_text_width(word_part)
|
||||
|
||||
# A word may span runs; the next iteration appends a fresh slot for
|
||||
# the continuation, so this run's fragment has to be measured now.
|
||||
if word_part:
|
||||
word_parts_widths[-1] = font.get_text_width(word_part)
|
||||
|
||||
return int(lines)
|
||||
|
||||
@@ -221,7 +255,7 @@ class ParagraphSizer:
|
||||
max_width -= (paragraph_format.left_indent or 0) + \
|
||||
(paragraph_format.right_indent or 0)
|
||||
|
||||
font = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
|
||||
font = _get_font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
|
||||
|
||||
# here self.paragraph.runs is not used because
|
||||
# it does not always return all runs (e.g. if they are inside hyperlink)
|
||||
@@ -265,3 +299,61 @@ class ParagraphSizer:
|
||||
raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
|
||||
|
||||
return ParagraphSizerResult(before, lines, line_height, line_spacing, after)
|
||||
|
||||
|
||||
def _is_blank_pPr(pPr) -> bool:
|
||||
"""An empty w:pPr carries no formatting and is equivalent to none at all.
|
||||
|
||||
Measuring a paragraph materialises one: paragraph_format reads go through
|
||||
get_or_add_pPr. Treating it as absent keeps a paragraph's key the same
|
||||
before and after its first measurement.
|
||||
"""
|
||||
return pPr is None or (not len(pPr) and not pPr.attrib)
|
||||
|
||||
|
||||
def _content_key(paragraph: Paragraph) -> bytes:
|
||||
"""Everything calculate_height() reads from the measured paragraph."""
|
||||
p = deepcopy(paragraph._p)
|
||||
if p.pPr is not None and _is_blank_pPr(p.pPr):
|
||||
p.remove(p.pPr)
|
||||
return etree.tostring(p)
|
||||
|
||||
|
||||
def _predecessor_key(paragraph: Paragraph) -> bytes:
|
||||
"""Everything calculate_height() reads from the *preceding* paragraph.
|
||||
|
||||
Only its style and its direct formatting matter (space_after feeds the
|
||||
collapsed space before the measured paragraph), so the runs are left out
|
||||
of the key. That also keeps the key stable once the predecessor has been
|
||||
appended to the body: attaching a w:p makes lxml move the drawing
|
||||
namespace declarations onto it, which would otherwise look like an edit.
|
||||
"""
|
||||
pPr = paragraph._p.pPr
|
||||
return b"" if _is_blank_pPr(pPr) else etree.tostring(deepcopy(pPr))
|
||||
|
||||
|
||||
def measure_paragraph(cache: dict, paragraph: Paragraph,
|
||||
previous_paragraph: Paragraph | None,
|
||||
max_width: Length) -> ParagraphSizerResult:
|
||||
"""calculate_height() memoised per owning renderable.
|
||||
|
||||
The same paragraph is measured several times per document: a table row is
|
||||
peeked before it is built, a listing's first line is probed before the
|
||||
loop, a figure caption is sized once to see whether the figure fits and
|
||||
again when it is emitted. One measurement costs milliseconds, serialising
|
||||
the two paragraphs costs microseconds, so the XML itself is the key — an
|
||||
edit between two calls misses the cache instead of returning a stale
|
||||
height. The cache lives on the caller, so it never spans documents (style
|
||||
definitions are not part of the key).
|
||||
"""
|
||||
key = (
|
||||
_content_key(paragraph),
|
||||
_predecessor_key(previous_paragraph) if previous_paragraph is not None else None,
|
||||
int(max_width),
|
||||
)
|
||||
result = cache.get(key)
|
||||
if result is None:
|
||||
result = ParagraphSizer(paragraph, previous_paragraph, max_width).calculate_height()
|
||||
cache[key] = result
|
||||
# Callers zero out .before on a new page, so hand out a private copy.
|
||||
return replace(result)
|
||||
|
||||
@@ -139,8 +139,6 @@ class Table(Renderable, RequiresNumbering):
|
||||
|
||||
def _peek_row_height(self, row_idx: int, layout_state: LayoutState) -> Length:
|
||||
"""Estimate row height without attaching cell paragraphs to a table."""
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
|
||||
row = self._rows[row_idx]
|
||||
merge_row = self._merge[row_idx]
|
||||
row_height = 0
|
||||
@@ -158,9 +156,9 @@ class Table(Renderable, RequiresNumbering):
|
||||
if merge_v != "continue":
|
||||
max_w = col_w * span - CELL_OFFSET
|
||||
for paragraph in row[col]:
|
||||
cell_height += ParagraphSizer(
|
||||
paragraph._docx_paragraph, None, max_w
|
||||
).calculate_height().full
|
||||
# Same measurement _build_docx_row() needs later, so the
|
||||
# cell paragraph is sized once per row width.
|
||||
cell_height += paragraph.measure(None, max_w).full
|
||||
row_height = max(cell_height, row_height)
|
||||
col += span
|
||||
row_height += Pt(0.5) + (
|
||||
@@ -169,24 +167,18 @@ class Table(Renderable, RequiresNumbering):
|
||||
return row_height
|
||||
|
||||
def _start_block_height(
|
||||
self, previous_rendered: RenderedInfo | None, layout_state: LayoutState
|
||||
self, caption: Caption, previous_rendered: RenderedInfo | None,
|
||||
layout_state: LayoutState
|
||||
) -> Length:
|
||||
"""Caption + header + first data row (method: no orphan title/header)."""
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
|
||||
# Dry-run caption sizing (temporary Caption; not yielded into the body).
|
||||
cap = Caption(
|
||||
self._parent, "Таблица", self._caption_info, self._number, True
|
||||
)
|
||||
prev_p = (
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph)
|
||||
else None
|
||||
)
|
||||
total = ParagraphSizer(
|
||||
cap._docx_paragraph, prev_p, layout_state.max_width
|
||||
).calculate_height().full
|
||||
total = caption.measure(prev_p, layout_state.max_width).full
|
||||
if self._rows:
|
||||
total += self._peek_row_height(0, layout_state)
|
||||
if len(self._rows) > 1:
|
||||
@@ -234,7 +226,8 @@ class Table(Renderable, RequiresNumbering):
|
||||
# not fit, push the whole block to the next page (layout leftover;
|
||||
# Word also gets keep_with_next on caption/header).
|
||||
leftover = Pt(0)
|
||||
start_h = self._start_block_height(previous_rendered, layout_state)
|
||||
caption = Caption(self._parent, "Таблица", self._caption_info, self._number, True)
|
||||
start_h = self._start_block_height(caption, previous_rendered, layout_state)
|
||||
if (
|
||||
layout_state.current_page_height > 0
|
||||
and start_h > layout_state.remaining_page_height
|
||||
@@ -244,8 +237,7 @@ class Table(Renderable, RequiresNumbering):
|
||||
previous_rendered = None
|
||||
|
||||
caption_rendered_infos = list(
|
||||
Caption(self._parent, "Таблица", self._caption_info, self._number, True)
|
||||
.render(previous_rendered, copy(layout_state))
|
||||
caption.render(previous_rendered, copy(layout_state))
|
||||
)
|
||||
if leftover and caption_rendered_infos:
|
||||
first = caption_rendered_infos[0]
|
||||
|
||||
+7
-5
@@ -27,10 +27,6 @@ if TYPE_CHECKING:
|
||||
|
||||
BOTTOM_MARGIN = Mm(20)
|
||||
|
||||
APPENDIX_RE = re.compile(
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Lettered appendix title: «Приложение А» or «Приложение А Название»
|
||||
APPENDIX_ITEM_RE = re.compile(
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$",
|
||||
@@ -342,7 +338,13 @@ class Renderer:
|
||||
try:
|
||||
first = next(infos)
|
||||
if isinstance(first, RenderedInfo) and first.height \
|
||||
>= self._layout_tracker.current_state.remaining_page_height:
|
||||
>= self._layout_tracker.current_state.remaining_page_height \
|
||||
and self._to_new_page:
|
||||
# Deferred figures go in first and move the page boundary,
|
||||
# so this block has to be laid out again against the state
|
||||
# they leave behind. Without anything queued the flush is a
|
||||
# no-op and re-rendering would only rebuild the same
|
||||
# subtree against the same state.
|
||||
self._flush_to_new_screen()
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user