"""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"