BigUpdate
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-09-03 10:44:08 +03:00
parent d2da20fdb2
commit 516abe7b83
177 changed files with 40178 additions and 2 deletions
+33
View File
@@ -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 …`.
+3
View File
@@ -0,0 +1,3 @@
"""Markdown → Flat ODF Text (.fodt), диалект md2gost."""
__version__ = "0.1.0"
+52
View File
@@ -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()
+37
View File
@@ -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()
+531
View File
@@ -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"
)
+22
View File
@@ -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, {"\"": "&quot;", "'": "&apos;"})
_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'}"
+159
View File
@@ -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"
"""