v0.5.3
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Igor20264
2026-09-10 10:50:15 +03:00
parent 658036f920
commit f7136bd8bd
95 changed files with 7165 additions and 8771 deletions
Binary file not shown.
+1 -1
View File
@@ -7,7 +7,7 @@
## Установка
```bash
poetry install
uv sync # окружение по uv.lock (канонический lock-файл)
# или
pip install -e .
```
+28
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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",
]
+1 -1
View File
@@ -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
-26
View File
@@ -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,
*,
-3
View File
@@ -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
View File
@@ -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:
-7
View File
@@ -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
View File
@@ -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)
+18 -13
View File
@@ -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)
+4 -1
View File
@@ -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
+17 -4
View File
@@ -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))
+1 -15
View File
@@ -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)
+13 -6
View File
@@ -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
+103 -11
View File
@@ -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)
+9 -17
View File
@@ -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
View File
@@ -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: