250 lines
8.0 KiB
Python
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"")
|
|
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"
|