"""Convert md2gost markdown AST → Flat ODF Text (.fodt) body + document.""" from __future__ import annotations import base64 import os import re from dataclasses import dataclass, field from pathlib import Path from md2gost.extended_markdown import markdown from md2gost.diagram_renderer import DIAGRAM_LANGS from md2gost.profiles import preprocess_markdown from mdexport import ( BlockVisitor, InlineRenderer, caption_tuple, is_contents_heading, parse_image_caption, reference_full_name, render_inline, walk_blocks, ) from .escape import escape_text, xml_id from .styles import FODT_NS, automatic_styles_xml, styles_xml @dataclass class EmitState(BlockVisitor): body: list[str] = field(default_factory=list) pending_caption: tuple[str | None, str | None, bool] | None = None biblio: list[tuple[str, str]] = field(default_factory=list) work_dir: Path | None = None fig_n: int = 0 tab_n: int = 0 lst_n: int = 0 labels: dict[str, str] = field(default_factory=dict) # id -> display number text list_style_i: int = 0 def w(self, s: str = "") -> None: self.body.append(s) def on_heading(self, element) -> None: _emit_heading(self, element) def on_table(self, element, caption_id: str | None, caption_text: str | None) -> None: _emit_table(self, element, caption_id, caption_text) def on_code(self, element, lang: str) -> None: _emit_code(self, element, lang) def on_list(self, element) -> None: _emit_list(self, element) def on_equation(self, element) -> None: _emit_equation(self, element) def on_image(self, image) -> None: _emit_image(self, image) def on_body_text(self, children) -> None: text = _inline(children, self).strip() if text: self.w(_p("Standard", text)) def on_quote(self, element) -> None: for child in element.children: if type(child).__name__ == "Paragraph": self.w(_p("Standard", _inline(child.children, self))) def on_other(self, element) -> None: self.on_body_text(element.children) class _FodtInline(InlineRenderer): def __init__(self, st: EmitState | None): self.st = st def escape(self, text: str) -> str: return escape_text(text) def code_span(self, node) -> str: return f'{escape_text(node.children)}' def emphasis(self, inner: str) -> str: return f'{inner}' def strong_emphasis(self, inner: str) -> str: return f'{inner}' def link(self, node, inner: str) -> str: href = escape_text(node.dest) return f'{inner}' def reference(self, node) -> str: key = f"{node.type.lower()}:{node.name}" num = (self.st.labels.get(key) if self.st else None) or "?" return escape_text(f"{reference_full_name(node.type)} {num}") def inline_equation(self, node) -> str: return ( f'' f"{escape_text(node.latex_equation)}" ) def line_break(self) -> str: return "" def _inline(children, st: EmitState | None = None) -> str: return render_inline(children, _FodtInline(st)) def _plain_inline(children) -> str: parts: list[str] = [] for child in children or []: t = type(child).__name__ if t in ("RawText", "Literal"): parts.append(getattr(child, "children", "") or "") elif t == "CodeSpan": parts.append(child.children or "") elif hasattr(child, "children"): parts.append(_plain_inline(child.children)) return "".join(parts) def _p(style: str, content: str) -> str: return f'{content}' def _h(level: int, content: str) -> str: style = {1: "Heading_20_1", 2: "Heading_20_2", 3: "Heading_20_3"}.get( level, "Heading_20_3" ) return f'{content}' def _emit_heading(st: EmitState, heading) -> None: text = _inline(heading.children, st).strip() plain = _plain_inline(heading.children).strip() level = min(int(heading.level), 3) if is_contents_heading(plain): st.w(_p("Centered", escape_text("СОДЕРЖАНИЕ"))) st.w( '' '' '' "Содержание" "" "" + _p("Standard", escape_text( "(Обновите содержание в LibreOffice Writer: ПКМ → Обновить индекс)" )) + "" ) return st.w(_h(level, text)) def _mime_for(path: Path) -> str: ext = path.suffix.lower() return { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".webp": "image/webp", }.get(ext, "application/octet-stream") def _emit_image(st: EmitState, img) -> None: dest = img.dest caption_id, caption_text = parse_image_caption(img, getattr(img, "unique_name", None)) if st.pending_caption and not caption_text: caption_id = caption_id or st.pending_caption[0] caption_text = st.pending_caption[1] or "" st.pending_caption = None src = dest if st.work_dir and not os.path.isabs(src): src = str(st.work_dir / dest) st.fig_n += 1 num = str(st.fig_n) if caption_id: st.labels[f"рисунок:{caption_id}"] = num frame_inner = "" if os.path.isfile(src): data = Path(src).read_bytes() b64 = base64.b64encode(data).decode("ascii") name = Path(dest).name.replace(" ", "_") mime = _mime_for(Path(src)) # width ~ 16cm (page content ≈ 17cm) frame_inner = ( f'' f'' f"{b64}" f"" ) else: frame_inner = escape_text(f"[изображение не найдено: {dest}]") st.w(_p("Centered", frame_inner)) cap = caption_text or "Рисунок" label = f"Рисунок {num} — {cap}" if caption_text else f"Рисунок {num}" bookmark = "" if caption_id: bid = xml_id(caption_id, "fig") bookmark = f'' st.w(_p("Caption", bookmark + escape_text(label))) def _cell_spans(rows, row: int, col: int) -> tuple[int, int] | None: """Return (rowspan, colspan) or None if cell is covered by a merge.""" cell = rows[row].children[col] if getattr(cell, "merge_v", "none") == "continue": return None if getattr(cell, "merge_h", "none") == "continue": return None rowspan = 1 if getattr(cell, "merge_v", "none") == "restart": r = row + 1 while r < len(rows) and getattr(rows[r].children[col], "merge_v", "none") == "continue": rowspan += 1 r += 1 colspan = 1 n_cols = len(rows[row].children) if getattr(cell, "merge_h", "none") == "restart": c = col + 1 while c < n_cols and getattr(rows[row].children[c], "merge_h", "none") == "continue": colspan += 1 c += 1 return rowspan, colspan def _emit_table(st: EmitState, table, caption_id: str | None, caption_text: str | None) -> None: rows = table.children if not rows: return st.tab_n += 1 num = str(st.tab_n) if caption_id: st.labels[f"таблица:{caption_id}"] = num if caption_text or caption_id: bookmark = "" if caption_id: bookmark = f'' label = f"Таблица {num}" if caption_text: label = f"Таблица {num} — {caption_text}" st.w(_p("Caption", bookmark + escape_text(label))) n_cols = len(rows[0].children) col_w = max(1, 1000 // n_cols) covered: set[tuple[int, int]] = set() parts: list[str] = [ f'' ] for _ in range(n_cols): parts.append( f'' ) # Header row: repeat on page break in Writer parts.append("") parts.append(_emit_table_row(rows, 0, n_cols, st, covered)) parts.append("") for ri in range(1, len(rows)): parts.append(_emit_table_row(rows, ri, n_cols, st, covered)) parts.append("") st.w("".join(parts)) def _emit_table_row( rows, ri: int, n_cols: int, st: EmitState, covered: set[tuple[int, int]] ) -> str: cells_xml: list[str] = [""] row = rows[ri] ci = 0 while ci < n_cols: if (ri, ci) in covered: cells_xml.append("") ci += 1 continue if ci >= len(row.children): cells_xml.append( '' f"{_p('Pcell', '')}" ) ci += 1 continue spans = _cell_spans(rows, ri, ci) if spans is None: cells_xml.append("") ci += 1 continue rowspan, colspan = spans attrs = ' table:style-name="Table1.1"' if rowspan > 1: attrs += f' table:number-rows-spanned="{rowspan}"' if colspan > 1: attrs += f' table:number-columns-spanned="{colspan}"' content = _inline(row.children[ci].children, st) cells_xml.append( f'' f"{_p('Pcell', content)}" f"" ) for dr in range(rowspan): for dc in range(colspan): if dr == 0 and dc == 0: continue covered.add((ri + dr, ci + dc)) for dc in range(1, colspan): cells_xml.append("") covered.discard((ri, ci + dc)) ci += colspan cells_xml.append("") return "".join(cells_xml) def _emit_code(st: EmitState, block, lang: str) -> None: source = block.children[0].children if block.children else "" cap_id, cap_text, _with_listing = st.take_caption() lang_l = (lang or "").strip().lower() if lang_l in DIAGRAM_LANGS: pass # v1: source listing only st.lst_n += 1 num = str(st.lst_n) if cap_id: st.labels[f"листинг:{cap_id}"] = num if cap_text or cap_id: bookmark = "" if cap_id: bookmark = f'' label = f"Листинг {num}" if cap_text: label = f"Листинг {num} — {cap_text}" st.w(_p("Caption", bookmark + escape_text(label))) for line in source.rstrip("\n").split("\n"): spaced = escape_text(line) def _spaces(m: re.Match) -> str: n = len(m.group(0)) return "" if n == 1 else f'' spaced = re.sub(r" +", _spaces, spaced) st.w(_p("Preformatted_20_Text", spaced if spaced else "")) def _emit_list(st: EmitState, lst) -> None: st.list_style_i += 1 style = "L1" attrs = f' text:style-name="{style}"' if lst.ordered: # use continue numbering via list level number style — simplified pass st.w(f"") for item in lst.children: st.w("") for ch in item.children: name = type(ch).__name__ if name == "Paragraph": st.w(_p("Standard", _inline(ch.children, st))) elif name == "List": _emit_list(st, ch) else: st.w(_p("Standard", _inline(getattr(ch, "children", []) or [], st))) st.w("") st.w("") def _emit_equation(st: EmitState, eq) -> None: cap_id = st.take_caption()[0] latex = eq.latex_equation.strip() bookmark = "" if cap_id: bookmark = f'' st.w(_p("Centered", bookmark + f'{escape_text(latex)}')) def _assign_numbers(st: EmitState, children) -> None: """ Number captioned objects up front so `@Тип:id` references resolve even when they appear before the object they point at. """ pending = None for el in children: name = type(el).__name__ if name == "Caption": pending = caption_tuple(el) continue if name == "Table" and pending: st.tab_n += 1 st.labels[f"таблица:{pending[0]}"] = str(st.tab_n) pending = None elif name in ("FencedCode", "CodeBlock") and pending: st.lst_n += 1 st.labels[f"листинг:{pending[0]}"] = str(st.lst_n) pending = None elif name == "Paragraph" and pending: if any(type(c).__name__ == "Image" for c in (el.children or [])): for c in el.children or []: if type(c).__name__ == "Image": st.fig_n += 1 cid = parse_image_caption(c, pending[0])[0] st.labels[f"рисунок:{cid}"] = str(st.fig_n) pending = None elif name == "Equation" and pending: pending = None def emit_body(md_text: str, *, work_dir: Path, emdash_to_hyphen: bool = False) -> EmitState: st = EmitState(work_dir=work_dir) pre = preprocess_markdown(md_text, emdash_to_hyphen=emdash_to_hyphen) # Two-pass: first collect captions/labels roughly by walking once for numbers, # then emit. Simple approach: single pass (refs before definition show "?"). # Better: first pass assign numbers, second emit. doc = markdown.parse(pre) _assign_numbers(st, doc.children) # reset counters for emit st.fig_n = 0 st.tab_n = 0 st.lst_n = 0 st.pending_caption = None walk_blocks(doc.children, st) if st.biblio: st.w(_h(1, escape_text("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"))) for key, txt in st.biblio: st.w(_p("Standard", escape_text(f"[{key}] {txt}"))) return st def emit_fodt(md_text: str, *, work_dir: Path, emdash_to_hyphen: bool = False) -> str: st = emit_body(md_text, work_dir=work_dir, emdash_to_hyphen=emdash_to_hyphen) body = "\n".join(st.body) return ( '\n' f"\n" " \n" " md2fodt\n" " md2fodt\n" " \n" f"{styles_xml()}" f"{automatic_styles_xml()}" " \n" " \n" f"{body}\n" " \n" " \n" "\n" )