@@ -0,0 +1,33 @@
|
||||
# md2fodt
|
||||
|
||||
Прямой пайплайн **Markdown → FODT** (Flat OpenDocument Text), диалект [md2gost](../md2gost/).
|
||||
|
||||
Без Word, без LibreOffice на этапе генерации. Один XML-файл `.fodt`, открывается в LibreOffice Writer / Collabora.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python -m md2fodt report.md -o report.fodt
|
||||
# или
|
||||
md2fodt report.md
|
||||
```
|
||||
|
||||
## Что умеет v1
|
||||
|
||||
- абзацы, **жирный** / *курсив* / `код`
|
||||
- заголовки H1–H3 (outline → можно обновить оглавление в Writer)
|
||||
- списки, таблицы с `table:header-rows` (повтор шапки при разрыве страницы)
|
||||
- merge ячеек `^` / `>` (как в md2gost)
|
||||
- рисунки (встраиваются base64 в FODT)
|
||||
- листинги, подписи / простые номера ссылок `@Рисунок:…`
|
||||
- библиография `[n]: …`
|
||||
|
||||
Стили: TNR 14, поля 30/10/20/20 мм (ГОСТ-ориентир).
|
||||
|
||||
## Не в v1
|
||||
|
||||
- «Продолжение таблицы N» (в ODF нет аналога LaTeX `longtable`)
|
||||
- титулы DOCX, PlantUML→картинка
|
||||
- полный паритет профилей PIS_custom / layout-tracker Word
|
||||
|
||||
Для DOCX по-прежнему: `python -m md2gost …`. Для PDF через LaTeX: `python -m md2latex …`.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Markdown → Flat ODF Text (.fodt), диалект md2gost."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python
|
||||
"""Пайплайн: MD (диалект md2gost) → Flat ODF Text (.fodt)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .converter import convert_md_to_fodt
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="md2fodt",
|
||||
description=(
|
||||
"Прямой MD → FODT (Flat OpenDocument Text), без Word и LibreOffice. "
|
||||
"Диалект разметки — как у md2gost."
|
||||
),
|
||||
)
|
||||
p.add_argument("filename", help="Исходный .md")
|
||||
p.add_argument(
|
||||
"-o", "--output",
|
||||
default=None,
|
||||
help="Путь к .fodt (по умолчанию: <stem>.fodt рядом с md)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--emdash-to-hyphen",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=False,
|
||||
help="Заменить «—» на «-» (по умолчанию выкл.)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
md = Path(args.filename)
|
||||
if not md.is_file():
|
||||
print(f"Error: file not found: {md}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if md.suffix.lower() != ".md":
|
||||
print("Error: filename must end with .md", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
out = convert_md_to_fodt(
|
||||
md,
|
||||
args.output,
|
||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
||||
)
|
||||
print(f"Generated document: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Assemble .fodt from markdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .emitter import emit_fodt
|
||||
|
||||
|
||||
def convert_md_to_fodt(
|
||||
md_path: str | Path,
|
||||
out_path: str | Path | None = None,
|
||||
*,
|
||||
emdash_to_hyphen: bool = False,
|
||||
) -> Path:
|
||||
"""
|
||||
Convert markdown to Flat ODF Text (.fodt).
|
||||
|
||||
Returns path to written .fodt file.
|
||||
"""
|
||||
md_path = Path(md_path).resolve()
|
||||
if out_path is None:
|
||||
out_path = md_path.with_suffix(".fodt")
|
||||
else:
|
||||
out_path = Path(out_path)
|
||||
if out_path.suffix.lower() != ".fodt":
|
||||
out_path = out_path.with_suffix(".fodt")
|
||||
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
xml = emit_fodt(
|
||||
md_text,
|
||||
work_dir=md_path.parent,
|
||||
emdash_to_hyphen=emdash_to_hyphen,
|
||||
)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(xml, encoding="utf-8")
|
||||
return out_path.resolve()
|
||||
@@ -0,0 +1,531 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""XML escape helpers for FODT output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from xml.sax.saxutils import escape as _xml_escape
|
||||
|
||||
|
||||
def escape_text(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return _xml_escape(s, {"\"": """, "'": "'"})
|
||||
|
||||
|
||||
_LABEL_RE = re.compile(r"[^\w\-]+", re.UNICODE)
|
||||
|
||||
|
||||
def xml_id(name: str | None, prefix: str = "obj") -> str:
|
||||
if not name:
|
||||
return prefix
|
||||
cleaned = _LABEL_RE.sub("-", name.strip()).strip("-")
|
||||
return f"{prefix}-{cleaned or 'x'}"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""GOST-ish office:styles / automatic-styles fragments for FODT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Page: A4, left 30mm, right 10mm, top/bottom 20mm
|
||||
# Body: Times New Roman 14pt, first-line indent 1.25cm, 1.5 line spacing
|
||||
|
||||
|
||||
def styles_xml() -> str:
|
||||
return """\
|
||||
<office:styles>
|
||||
<style:default-style style:family="paragraph">
|
||||
<style:paragraph-properties fo:text-align="justify" style:justify-single-word="false"
|
||||
fo:orphans="2" fo:widows="2"/>
|
||||
<style:text-properties style:font-name="Times New Roman" fo:font-size="14pt"
|
||||
style:font-name-asian="Times New Roman" style:font-size-asian="14pt"
|
||||
style:font-name-complex="Times New Roman" style:font-size-complex="14pt"/>
|
||||
</style:default-style>
|
||||
<style:style style:name="Standard" style:family="paragraph" style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0cm"
|
||||
fo:line-height="150%" fo:text-align="justify"
|
||||
fo:text-indent="1.25cm"/>
|
||||
<style:text-properties style:font-name="Times New Roman" fo:font-size="14pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Heading" style:family="paragraph" style:class="text"/>
|
||||
<style:style style:name="Heading_20_1" style:display-name="Heading 1"
|
||||
style:family="paragraph" style:parent-style-name="Heading" style:default-outline-level="1"
|
||||
style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0.4cm" fo:margin-bottom="0.3cm"
|
||||
fo:text-align="left" fo:text-indent="1.25cm" fo:line-height="150%"
|
||||
fo:keep-with-next="always"/>
|
||||
<style:text-properties fo:font-weight="bold" style:font-name="Times New Roman"
|
||||
fo:font-size="14pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Heading_20_2" style:display-name="Heading 2"
|
||||
style:family="paragraph" style:parent-style-name="Heading" style:default-outline-level="2"
|
||||
style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0.3cm" fo:margin-bottom="0.2cm"
|
||||
fo:text-align="left" fo:text-indent="1.25cm" fo:line-height="150%"
|
||||
fo:keep-with-next="always"/>
|
||||
<style:text-properties fo:font-weight="bold" style:font-name="Times New Roman"
|
||||
fo:font-size="14pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Heading_20_3" style:display-name="Heading 3"
|
||||
style:family="paragraph" style:parent-style-name="Heading" style:default-outline-level="3"
|
||||
style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0.2cm" fo:margin-bottom="0.2cm"
|
||||
fo:text-align="left" fo:text-indent="1.25cm" fo:line-height="150%"
|
||||
fo:keep-with-next="always"/>
|
||||
<style:text-properties fo:font-weight="bold" style:font-name="Times New Roman"
|
||||
fo:font-size="14pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Caption" style:family="paragraph" style:class="extra">
|
||||
<style:paragraph-properties fo:text-align="center" fo:text-indent="0cm"
|
||||
fo:margin-top="0.2cm" fo:margin-bottom="0.2cm" fo:line-height="150%"/>
|
||||
<style:text-properties style:font-name="Times New Roman" fo:font-size="14pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Preformatted_20_Text" style:display-name="Preformatted Text"
|
||||
style:family="paragraph" style:class="html">
|
||||
<style:paragraph-properties fo:text-align="left" fo:text-indent="0cm"
|
||||
fo:margin-left="0cm" fo:line-height="100%"/>
|
||||
<style:text-properties style:font-name="Courier New" fo:font-size="12pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Centered" style:family="paragraph" style:parent-style-name="Standard">
|
||||
<style:paragraph-properties fo:text-align="center" fo:text-indent="0cm"/>
|
||||
</style:style>
|
||||
<text:outline-style>
|
||||
<text:outline-level-style text:level="1" style:num-format="">
|
||||
<style:list-level-properties text:min-label-distance="0.4cm"/>
|
||||
</text:outline-level-style>
|
||||
<text:outline-level-style text:level="2" style:num-format="">
|
||||
<style:list-level-properties text:min-label-distance="0.4cm"/>
|
||||
</text:outline-level-style>
|
||||
<text:outline-level-style text:level="3" style:num-format="">
|
||||
<style:list-level-properties text:min-label-distance="0.4cm"/>
|
||||
</text:outline-level-style>
|
||||
</text:outline-style>
|
||||
</office:styles>
|
||||
"""
|
||||
|
||||
|
||||
def automatic_styles_xml() -> str:
|
||||
return """\
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="21.0cm" fo:page-height="29.7cm"
|
||||
style:num-format="1" style:print-orientation="portrait"
|
||||
fo:margin-top="2.0cm" fo:margin-bottom="2.0cm"
|
||||
fo:margin-left="3.0cm" fo:margin-right="1.0cm"
|
||||
style:writing-mode="lr-tb"/>
|
||||
</style:page-layout>
|
||||
<style:style style:name="Tbold" style:family="text">
|
||||
<style:text-properties fo:font-weight="bold"/>
|
||||
</style:style>
|
||||
<style:style style:name="Titalic" style:family="text">
|
||||
<style:text-properties fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="Tcode" style:family="text">
|
||||
<style:text-properties style:font-name="Courier New" fo:font-size="12pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="fr1" style:family="graphic" style:parent-style-name="Graphics">
|
||||
<style:graphic-properties style:horizontal-pos="center" style:horizontal-rel="paragraph"
|
||||
style:mirror="none" fo:clip="rect(0cm, 0cm, 0cm, 0cm)" draw:luminance="0%"
|
||||
draw:contrast="0%" draw:red="0%" draw:green="0%" draw:blue="0%" draw:gamma="100%"
|
||||
draw:color-inversion="false" draw:image-opacity="100%" draw:color-mode="standard"/>
|
||||
</style:style>
|
||||
<style:style style:name="Table1" style:family="table">
|
||||
<style:table-properties table:border-model="collapsing" style:rel-width="100%"
|
||||
table:align="margins"/>
|
||||
</style:style>
|
||||
<style:style style:name="Table1.A" style:family="table-column">
|
||||
<style:table-column-properties style:rel-column-width="1000*"/>
|
||||
</style:style>
|
||||
<style:style style:name="Table1.1" style:family="table-cell">
|
||||
<style:table-cell-properties fo:padding="0.1cm" fo:border="0.5pt solid #000000"/>
|
||||
</style:style>
|
||||
<style:style style:name="Pcell" style:family="paragraph" style:parent-style-name="Standard">
|
||||
<style:paragraph-properties fo:text-indent="0cm" fo:text-align="left" fo:line-height="100%"/>
|
||||
</style:style>
|
||||
<style:style style:name="L1" style:family="list">
|
||||
<text:list-level-style-bullet text:level="1" text:style-name="Bullet_20_Symbols"
|
||||
text:bullet-char="–">
|
||||
<style:list-level-properties text:space-before="0cm" text:min-label-width="0.75cm"/>
|
||||
</text:list-level-style-bullet>
|
||||
<text:list-level-style-number text:level="1" text:style-name="Numbering_20_Symbols"
|
||||
style:num-suffix="." style:num-format="1">
|
||||
<style:list-level-properties text:space-before="0cm" text:min-label-width="0.75cm"/>
|
||||
</text:list-level-style-number>
|
||||
</style:style>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
"""
|
||||
|
||||
|
||||
FODT_NS = """\
|
||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
||||
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"
|
||||
xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"
|
||||
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
|
||||
xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0"
|
||||
xmlns:math="http://www.w3.org/1998/Math/MathML"
|
||||
xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0"
|
||||
xmlns:script="urn:oasis:names:tc:opendocument:xmlns:script:1.0"
|
||||
xmlns:dom="http://www.w3.org/2001/xml-events"
|
||||
xmlns:xforms="http://www.w3.org/2002/xforms"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
office:version="1.3"
|
||||
office:mimetype="application/vnd.oasis.opendocument.text"
|
||||
"""
|
||||
Reference in New Issue
Block a user