532 lines
18 KiB
Python
532 lines
18 KiB
Python
"""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 .escape import escape_text, xml_id
|
|
from .styles import FODT_NS, automatic_styles_xml, styles_xml
|
|
|
|
|
|
@dataclass
|
|
class EmitState:
|
|
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 _inline(children, st: EmitState | None = None) -> str:
|
|
parts: list[str] = []
|
|
for child in children or []:
|
|
t = type(child).__name__
|
|
if t in ("RawText", "Literal"):
|
|
parts.append(escape_text(getattr(child, "children", "") or ""))
|
|
elif t == "CodeSpan":
|
|
parts.append(
|
|
f'<text:span text:style-name="Tcode">{escape_text(child.children)}</text:span>'
|
|
)
|
|
elif t == "Emphasis":
|
|
parts.append(
|
|
f'<text:span text:style-name="Titalic">{_inline(child.children, st)}</text:span>'
|
|
)
|
|
elif t == "StrongEmphasis":
|
|
parts.append(
|
|
f'<text:span text:style-name="Tbold">{_inline(child.children, st)}</text:span>'
|
|
)
|
|
elif t == "Link":
|
|
href = escape_text(child.dest)
|
|
parts.append(
|
|
f'<text:a xlink:type="simple" xlink:href="{href}">'
|
|
f"{_inline(child.children, st)}</text:a>"
|
|
)
|
|
elif t == "Reference":
|
|
full = {
|
|
"рисунок": "Рисунок",
|
|
"таблица": "Таблица",
|
|
"листинг": "Листинг",
|
|
"формула": "формула",
|
|
}.get(child.type.lower(), child.type)
|
|
key = f"{child.type.lower()}:{child.name}"
|
|
num = (st.labels.get(key) if st else None) or "?"
|
|
parts.append(escape_text(f"{full} {num}"))
|
|
elif t == "InlineEquation":
|
|
parts.append(
|
|
f'<text:span text:style-name="Tcode">'
|
|
f"{escape_text(child.latex_equation)}</text:span>"
|
|
)
|
|
elif t == "Image":
|
|
parts.append("")
|
|
elif t == "LineBreak":
|
|
parts.append("<text:line-break/>")
|
|
elif hasattr(child, "children"):
|
|
parts.append(_inline(child.children, st))
|
|
else:
|
|
parts.append(escape_text(str(child)))
|
|
return "".join(parts)
|
|
|
|
|
|
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'<text:p text:style-name="{style}">{content}</text:p>'
|
|
|
|
|
|
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'<text:h text:style-name="{style}" text:outline-level="{level}">{content}</text:h>'
|
|
|
|
|
|
def _emit_heading(st: EmitState, heading) -> None:
|
|
text = _inline(heading.children, st).strip()
|
|
plain = _plain_inline(heading.children).strip()
|
|
upper = plain.upper()
|
|
level = min(int(heading.level), 3)
|
|
|
|
if upper == "СОДЕРЖАНИЕ" or upper.startswith("СОДЕРЖАНИЕ"):
|
|
st.w(_p("Centered", escape_text("СОДЕРЖАНИЕ")))
|
|
st.w(
|
|
'<text:table-of-content text:style-name="Standard" text:protected="false" '
|
|
'text:name="TOC">'
|
|
'<text:table-of-content-source text:outline-level="3">'
|
|
'<text:index-title-template text:style-name="Centered">'
|
|
"Содержание</text:index-title-template>"
|
|
"</text:table-of-content-source>"
|
|
"<text:index-body>"
|
|
+ _p("Standard", escape_text(
|
|
"(Обновите содержание в LibreOffice Writer: ПКМ → Обновить индекс)"
|
|
))
|
|
+ "</text:index-body></text:table-of-content>"
|
|
)
|
|
return
|
|
|
|
st.w(_h(level, text))
|
|
|
|
|
|
def _emit_paragraph(st: EmitState, para) -> None:
|
|
text_probe = ""
|
|
for ch in para.children or []:
|
|
if type(ch).__name__ in ("RawText", "Literal"):
|
|
text_probe += ch.children or ""
|
|
m = re.match(r"^\[([^\]]+)\]:\s*(.+)$", text_probe.strip())
|
|
if m:
|
|
st.biblio.append((m.group(1), m.group(2).strip()))
|
|
return
|
|
|
|
has_image = any(type(c).__name__ == "Image" for c in (para.children or []))
|
|
if has_image:
|
|
for c in para.children:
|
|
if type(c).__name__ == "Image":
|
|
_emit_image(st, c)
|
|
others = [c for c in para.children if type(c).__name__ != "Image"]
|
|
t = _inline(others, st).strip()
|
|
if t:
|
|
st.w(_p("Standard", t))
|
|
return
|
|
|
|
raw = _inline(para.children, st).strip()
|
|
if raw:
|
|
st.w(_p("Standard", raw))
|
|
|
|
|
|
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
|
|
title = getattr(img, "title", None) or ""
|
|
caption_id = getattr(img, "unique_name", None)
|
|
caption_text = title
|
|
if title.startswith("%"):
|
|
parts = title[1:].split(None, 1)
|
|
caption_id = parts[0]
|
|
caption_text = parts[1] if len(parts) > 1 else ""
|
|
|
|
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'<draw:frame draw:style-name="fr1" draw:name="img{st.fig_n}" '
|
|
f'text:anchor-type="as-char" svg:width="16cm" svg:height="9cm" '
|
|
f'draw:z-index="0">'
|
|
f'<draw:image xlink:href="Pictures/{escape_text(name)}" '
|
|
f'xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad" '
|
|
f'draw:mime-type="{mime}">'
|
|
f"<office:binary-data>{b64}</office:binary-data>"
|
|
f"</draw:image></draw:frame>"
|
|
)
|
|
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'<text:bookmark text:name="{bid}"/>'
|
|
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'<text:bookmark text:name="{xml_id(caption_id, "tab")}"/>'
|
|
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'<table:table table:name="Table{st.tab_n}" table:style-name="Table1">'
|
|
]
|
|
for _ in range(n_cols):
|
|
parts.append(
|
|
f'<table:table-column table:style-name="Table1.A" '
|
|
f'style:rel-column-width="{col_w}*"/>'
|
|
)
|
|
|
|
# Header row: repeat on page break in Writer
|
|
parts.append("<table:table-header-rows>")
|
|
parts.append(_emit_table_row(rows, 0, n_cols, st, covered))
|
|
parts.append("</table:table-header-rows>")
|
|
|
|
for ri in range(1, len(rows)):
|
|
parts.append(_emit_table_row(rows, ri, n_cols, st, covered))
|
|
|
|
parts.append("</table:table>")
|
|
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] = ["<table:table-row>"]
|
|
row = rows[ri]
|
|
ci = 0
|
|
while ci < n_cols:
|
|
if (ri, ci) in covered:
|
|
cells_xml.append("<table:covered-table-cell/>")
|
|
ci += 1
|
|
continue
|
|
if ci >= len(row.children):
|
|
cells_xml.append(
|
|
'<table:table-cell table:style-name="Table1.1" office:value-type="string">'
|
|
f"{_p('Pcell', '')}</table:table-cell>"
|
|
)
|
|
ci += 1
|
|
continue
|
|
spans = _cell_spans(rows, ri, ci)
|
|
if spans is None:
|
|
cells_xml.append("<table:covered-table-cell/>")
|
|
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'<table:table-cell{attrs} office:value-type="string">'
|
|
f"{_p('Pcell', content)}"
|
|
f"</table:table-cell>"
|
|
)
|
|
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("<table:covered-table-cell/>")
|
|
covered.discard((ri, ci + dc))
|
|
ci += colspan
|
|
cells_xml.append("</table:table-row>")
|
|
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 = (None, None, False)
|
|
if st.pending_caption:
|
|
cap_id, cap_text, _with_listing = st.pending_caption
|
|
st.pending_caption = None
|
|
|
|
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'<text:bookmark text:name="{xml_id(cap_id, "lst")}"/>'
|
|
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 "<text:s/>" if n == 1 else f'<text:s text:c="{n}"/>'
|
|
|
|
spaced = re.sub(r" +", _spaces, spaced)
|
|
st.w(_p("Preformatted_20_Text", spaced if spaced else "<text:s/>"))
|
|
|
|
|
|
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"<text:list{attrs}>")
|
|
for item in lst.children:
|
|
st.w("<text:list-item>")
|
|
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("</text:list-item>")
|
|
st.w("</text:list>")
|
|
|
|
|
|
def _emit_equation(st: EmitState, eq) -> None:
|
|
cap_id = None
|
|
if st.pending_caption:
|
|
cap_id = st.pending_caption[0]
|
|
st.pending_caption = None
|
|
latex = eq.latex_equation.strip()
|
|
bookmark = ""
|
|
if cap_id:
|
|
bookmark = f'<text:bookmark text:name="{xml_id(cap_id, "eq")}"/>'
|
|
st.w(_p("Centered", bookmark + f'<text:span text:style-name="Tcode">{escape_text(latex)}</text:span>'))
|
|
|
|
|
|
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)
|
|
|
|
# Pass 1 — assign numbers for captions attached to tables/images/listings
|
|
pending = None
|
|
for el in doc.children:
|
|
name = type(el).__name__
|
|
if name == "Caption":
|
|
pending = (el.unique_name, el.text, getattr(el, "with_listing", False))
|
|
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 = pending[0]
|
|
title = getattr(c, "title", "") or ""
|
|
if title.startswith("%"):
|
|
cid = title[1:].split(None, 1)[0]
|
|
st.labels[f"рисунок:{cid}"] = str(st.fig_n)
|
|
pending = None
|
|
elif name == "Equation" and pending:
|
|
pending = None
|
|
|
|
# reset counters for emit
|
|
st.fig_n = 0
|
|
st.tab_n = 0
|
|
st.lst_n = 0
|
|
st.pending_caption = None
|
|
|
|
for el in doc.children:
|
|
name = type(el).__name__
|
|
if name == "BlankLine":
|
|
continue
|
|
if name == "Caption":
|
|
st.pending_caption = (
|
|
el.unique_name,
|
|
el.text,
|
|
getattr(el, "with_listing", False),
|
|
)
|
|
continue
|
|
if name == "Heading":
|
|
_emit_heading(st, el)
|
|
continue
|
|
if name == "Paragraph":
|
|
_emit_paragraph(st, el)
|
|
continue
|
|
if name == "Table":
|
|
cid = ct = None
|
|
if st.pending_caption:
|
|
cid, ct, _ = st.pending_caption
|
|
st.pending_caption = None
|
|
_emit_table(st, el, cid, ct)
|
|
continue
|
|
if name in ("FencedCode", "CodeBlock"):
|
|
lang = getattr(el, "lang", "") or ""
|
|
_emit_code(st, el, lang)
|
|
continue
|
|
if name == "List":
|
|
_emit_list(st, el)
|
|
continue
|
|
if name == "Equation":
|
|
_emit_equation(st, el)
|
|
continue
|
|
if name == "TOC":
|
|
continue
|
|
if name == "Quote":
|
|
for ch in el.children:
|
|
if type(ch).__name__ == "Paragraph":
|
|
st.w(_p("Standard", _inline(ch.children, st)))
|
|
continue
|
|
if hasattr(el, "children"):
|
|
t = _inline(el.children, st).strip()
|
|
if t:
|
|
st.w(_p("Standard", t))
|
|
|
|
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 (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
f"<office:document {FODT_NS.strip()}>\n"
|
|
" <office:meta>\n"
|
|
" <dc:title>md2fodt</dc:title>\n"
|
|
" <meta:generator>md2fodt</meta:generator>\n"
|
|
" </office:meta>\n"
|
|
f"{styles_xml()}"
|
|
f"{automatic_styles_xml()}"
|
|
" <office:body>\n"
|
|
" <office:text>\n"
|
|
f"{body}\n"
|
|
" </office:text>\n"
|
|
" </office:body>\n"
|
|
"</office:document>\n"
|
|
)
|