Files
md_to_gost/word2md/emit.py
T
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

250 lines
8.0 KiB
Python

"""Emit md2gost dialect Markdown from IR blocks."""
from __future__ import annotations
from .blocks import (
Block,
CaptionBlock,
EquationBlock,
HeadingBlock,
ImageBlock,
InlineSpan,
ListBlock,
ListingBlock,
PageBreakBlock,
ParagraphBlock,
TableBlock,
TocPlaceholder,
)
# Characters that must stay literal after DOCX→MD (Word stores bare '*', not '\*').
# Do not escape '_': md2gost labels use it (@Рисунок:fig1_1).
_MD_SPECIAL = str.maketrans(
{
"\\": "\\\\",
"*": "\\*",
"`": "\\`",
}
)
def escape_md_inline(text: str) -> str:
"""Escape Markdown inline metacharacters in plain text from DOCX."""
if not text:
return ""
return text.translate(_MD_SPECIAL)
def _escape_md_cell(text: str) -> str:
return escape_md_inline(text or "").replace("|", "\\|").replace("\n", " ")
def _render_spans(spans: list[InlineSpan]) -> str:
parts: list[str] = []
for s in spans:
if s.math:
parts.append(f"${s.math}$")
continue
text = escape_md_inline(s.text or "")
if s.href:
parts.append(f"[{text}]({s.href})")
continue
if s.bold and s.italic:
text = f"***{text}***"
elif s.bold:
text = f"**{text}**"
elif s.italic:
text = f"*{text}*"
if s.strike:
text = f"~~{text}~~"
parts.append(text)
return "".join(parts)
def _emit_caption_line(unique_id: str, text: str | None = None, *, landscape: bool = False) -> str:
title = escape_md_inline((text or "").strip())
flags = " +landscape" if landscape else ""
if title:
return f"%{unique_id} {title}{flags}".rstrip()
return f"%{unique_id}{flags}".rstrip()
def _last_nonempty(chunks: list[str]) -> str:
for prev in reversed(chunks):
if prev.strip():
return prev
return ""
def _emit_table(tbl: TableBlock) -> str:
if not tbl.rows:
return ""
lines: list[str] = []
for r, row in enumerate(tbl.rows):
cells = []
for c, cell in enumerate(row):
if tbl.merges and r < len(tbl.merges) and c < len(tbl.merges[r]):
v, h = tbl.merges[r][c]
if h == "continue":
cells.append(">")
continue
if v == "continue":
cells.append("^")
continue
cells.append(_escape_md_cell(cell if cell not in ("^", ">") else cell))
lines.append("| " + " | ".join(cells) + " |")
if r == 0:
lines.append("| " + " | ".join("---" for _ in cells) + " |")
return "\n".join(lines)
def emit_markdown(blocks: list[Block], *, emit_page_breaks: bool = False) -> str:
chunks: list[str] = []
i = 0
while i < len(blocks):
b = blocks[i]
if isinstance(b, HeadingBlock):
hashes = "#" * max(1, min(b.level, 6))
title = escape_md_inline(b.text or "")
if not b.numbered:
# Dialect flag '*' after '# ' is structural — not escaped.
chunks.append(f"{hashes} *{title}")
else:
chunks.append(f"{hashes} {title}")
chunks.append("")
i += 1
continue
if isinstance(b, TocPlaceholder):
chunks.append("[TOC]")
chunks.append("")
i += 1
continue
if isinstance(b, CaptionBlock):
if b.is_continuation:
i += 1
continue
nxt = blocks[i + 1] if i + 1 < len(blocks) else None
if isinstance(nxt, ImageBlock) and b.kind == "figure":
nxt.caption_id = nxt.caption_id or b.unique_id
nxt.caption_text = nxt.caption_text or b.text
i += 1
continue
if isinstance(nxt, TableBlock) and b.kind == "table":
nxt.caption_id = nxt.caption_id or b.unique_id
nxt.caption_text = nxt.caption_text or b.text
if b.landscape:
nxt.landscape = True
chunks.append(_emit_caption_line(
nxt.caption_id or b.unique_id,
nxt.caption_text or b.text,
landscape=nxt.landscape,
))
chunks.append("")
i += 1
continue
if isinstance(nxt, ListingBlock) and b.kind == "listing":
nxt.caption_id = nxt.caption_id or b.unique_id
nxt.caption_text = nxt.caption_text or b.text
chunks.append(_emit_caption_line(nxt.caption_id or b.unique_id, nxt.caption_text))
chunks.append("")
i += 1
continue
chunks.append(_emit_caption_line(b.unique_id, b.text, landscape=b.landscape))
chunks.append("")
i += 1
continue
if isinstance(b, ImageBlock):
# Safety: caption still after image in IR
nxt = blocks[i + 1] if i + 1 < len(blocks) else None
if (
isinstance(nxt, CaptionBlock)
and nxt.kind == "figure"
and not nxt.is_continuation
):
b.caption_id = b.caption_id or nxt.unique_id
b.caption_text = b.caption_text or nxt.text
# skip the caption block
i += 1
title = ""
if b.caption_id:
title_body = escape_md_inline((b.caption_text or "").strip())
title = f" \"%{b.caption_id}" + (f" {title_body}" if title_body else "") + "\""
alt = escape_md_inline((b.alt or "рисунок").replace('"', "'"))
chunks.append(f"![{alt}]({b.rel_path}{title})")
chunks.append("")
i += 1
continue
if isinstance(b, TableBlock):
if b.caption_id:
prev = _last_nonempty(chunks)
if not prev.startswith(f"%{b.caption_id}"):
chunks.append(_emit_caption_line(b.caption_id, b.caption_text, landscape=b.landscape))
chunks.append("")
chunks.append(_emit_table(b))
chunks.append("")
i += 1
continue
if isinstance(b, ListingBlock):
if b.caption_id:
prev = _last_nonempty(chunks)
if not prev.startswith(f"%{b.caption_id}"):
chunks.append(_emit_caption_line(b.caption_id, b.caption_text))
chunks.append("")
lang = b.language or ""
body = "\n".join(line.rstrip("\n") for line in b.lines)
chunks.append(f"```{lang}")
chunks.append(body)
chunks.append("```")
chunks.append("")
i += 1
continue
if isinstance(b, EquationBlock):
if b.unique_id:
chunks.append(f"%{b.unique_id}")
chunks.append("$$")
chunks.append(b.latex.strip())
chunks.append("$$")
chunks.append("")
i += 1
continue
if isinstance(b, ListBlock):
for idx, item in enumerate(b.items, start=1):
body = escape_md_inline(item)
if b.ordered:
chunks.append(f"{idx}. {body}")
else:
chunks.append(f"- {body}")
chunks.append("")
i += 1
continue
if isinstance(b, ParagraphBlock):
line = _render_spans(b.spans).strip()
if line:
chunks.append(line)
chunks.append("")
i += 1
continue
if isinstance(b, PageBreakBlock):
if emit_page_breaks:
chunks.append("---")
chunks.append("")
i += 1
continue
i += 1
text = "\n".join(chunks)
while "\n\n\n" in text:
text = text.replace("\n\n\n", "\n\n")
return text.strip() + "\n"