v0.5.2
Python application / build (push) Waiting to run

Что то сделал
This commit is contained in:
Igor20264
2026-09-08 19:37:54 +03:00
parent 1a5b35eb54
commit 510f7e7adf
90 changed files with 11720 additions and 5547 deletions
+9
View File
@@ -0,0 +1,9 @@
# word2md
Импорт **DOCX → Markdown** в диалекте md2gost.
```bash
python -m word2md report.docx -o report.md
```
Документация: [`docs/word2md.md`](../docs/word2md.md).
+7
View File
@@ -0,0 +1,7 @@
"""Word DOCX → Markdown (диалект md2gost)."""
__version__ = "0.1.0"
from .pipeline import ImportRequest, ImportResult, convert_docx
__all__ = ["ImportRequest", "ImportResult", "convert_docx", "__version__"]
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python
"""CLI: DOCX → Markdown (диалект md2gost)."""
from __future__ import annotations
import argparse
import sys
from .pipeline import ImportRequest, convert_docx
def main() -> None:
p = argparse.ArgumentParser(
prog="word2md",
description=(
"Импорт DOCX в Markdown диалекта md2gost "
"(заголовки # *, подписи %id, @Рисунок:id, таблицы, листинги)."
),
)
p.add_argument("filename", help="Исходный .docx")
p.add_argument(
"-o", "--output",
default=None,
help="Путь к .md (по умолчанию: <stem>.md рядом с docx)",
)
p.add_argument(
"--media-dir",
default=None,
help="Каталог для картинок (по умолчанию: <stem>_media рядом с .md)",
)
p.add_argument(
"--keep-toc-pages",
action=argparse.BooleanOptionalAction,
default=False,
help="Не выкидывать строки содержания Word (по умолчанию выкл.: только [TOC])",
)
p.add_argument(
"--pagebreaks",
action=argparse.BooleanOptionalAction,
default=False,
help="Писать --- на разрывах страниц Word (для --hr-pagebreak при обратной сборке)",
)
args = p.parse_args()
result = convert_docx(
ImportRequest(
filename=args.filename,
output=args.output,
media_dir=args.media_dir,
keep_toc_pages=args.keep_toc_pages,
emit_page_breaks=args.pagebreaks,
)
)
sys.exit(result.exit_code)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
"""Intermediate IR blocks between DOCX walk and Markdown emit."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class InlineSpan:
text: str
bold: bool = False
italic: bool = False
strike: bool = False
href: str | None = None # external URL
math: str | None = None # inline latex-ish
@dataclass
class HeadingBlock:
level: int
text: str
numbered: bool = True # False → # *TITLE
@dataclass
class ParagraphBlock:
spans: list[InlineSpan] = field(default_factory=list)
style: str = "Normal"
is_bibliography: bool = False
biblio_key: str | None = None
@dataclass
class CaptionBlock:
kind: str # figure | table | listing
number: str
text: str | None = None
is_continuation: bool = False
landscape: bool = False
unique_id: str = ""
@dataclass
class ImageBlock:
rel_path: str # relative to md file
alt: str = ""
caption_id: str | None = None
caption_text: str | None = None
@dataclass
class TableBlock:
rows: list[list[str]]
merges: list[list[tuple[str, str]]] | None = None # (v, h) per cell
caption_id: str | None = None
caption_text: str | None = None
landscape: bool = False
@dataclass
class ListingBlock:
lines: list[str]
language: str = ""
caption_id: str | None = None
caption_text: str | None = None
@dataclass
class EquationBlock:
latex: str
number: str | None = None
unique_id: str | None = None
@dataclass
class ListBlock:
ordered: bool
items: list[str]
level: int = 1
@dataclass
class TocPlaceholder:
"""Emitted as # *СОДЕРЖАНИЕ + [TOC]."""
@dataclass
class PageBreakBlock:
pass
@dataclass
class SkipNote:
"""Logged skip; not emitted."""
message: str
Block = (
HeadingBlock
| ParagraphBlock
| CaptionBlock
| ImageBlock
| TableBlock
| ListingBlock
| EquationBlock
| ListBlock
| TocPlaceholder
| PageBreakBlock
| SkipNote
)
+85
View File
@@ -0,0 +1,85 @@
"""Parse ГОСТ-style captions: Рисунок / Таблица / Листинг (+ продолжения)."""
from __future__ import annotations
import re
from dataclasses import dataclass
from md2gost.word_fix import parse_caption_text
_FIGURE_RE = re.compile(
r"^(?:Рисунок|Рис\.?)\s+(?P<number>[\d.]+)\s*(?:[—–\-]\s*(?P<title>.+))?$",
re.IGNORECASE,
)
_TABLE_FULL_RE = re.compile(
r"^(?:Продолжение\s+)?"
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
r"(?P<number>[\d.]+)"
r"(?:\s*[—–\-]\s*(?P<title>.+))?$",
re.IGNORECASE,
)
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
@dataclass
class ParsedCaption:
kind: str # figure | table | listing
number: str
text: str | None = None
is_continuation: bool = False
def caption_unique_id(kind: str, number: str) -> str:
"""Stable id from display number: 1.1 → fig1_1 / tbl1_1 / lst1_1."""
safe = str(number).replace(".", "_")
prefix = {"figure": "fig", "table": "tbl", "listing": "lst"}.get(kind, "obj")
return f"{prefix}{safe}"
def parse_any_caption(text: str) -> ParsedCaption | None:
"""Parse figure / table / listing captions (including «Продолжение…»)."""
raw = (text or "").replace("\r", "").replace("\x07", "").strip()
if not raw:
return None
first = raw.split("\n", 1)[0].strip()
m = _FIGURE_RE.match(first)
if m:
title = (m.group("title") or "").strip().rstrip(".") or None
return ParsedCaption(kind="figure", number=m.group("number"), text=title)
info = parse_caption_text(first)
if info is not None:
title = None
m2 = _TABLE_FULL_RE.match(first)
if m2 and m2.group("title"):
title = m2.group("title").strip().rstrip(".") or None
else:
# Strip leading «Таблица N — » / «Продолжение…» for leftover title
rest = _TABLE_FULL_RE.sub("", first).strip()
if rest.startswith(("", "", "-")):
rest = rest[1:].strip()
title = rest.rstrip(".") or None if rest else None
return ParsedCaption(
kind=info.kind,
number=info.number,
text=title,
is_continuation=info.is_continuation,
)
m3 = _TABLE_FULL_RE.match(first)
if m3:
kind_raw = m3.group("kind").lower()
if kind_raw.startswith("табл"):
kind = "table"
elif kind_raw.startswith("лист"):
kind = "listing"
else:
return None
return ParsedCaption(
kind=kind,
number=m3.group("number"),
text=(m3.group("title") or "").strip().rstrip(".") or None,
is_continuation=bool(_CONTINUATION_RE.match(first)),
)
return None
+167
View File
@@ -0,0 +1,167 @@
"""Classify DOCX paragraphs/tables into IR kinds (style first, then heuristics)."""
from __future__ import annotations
import re
from md2gost.renderable.toc import SPECIAL_TITLES
from .captions import parse_any_caption
CAPTION_STYLE_KINDS = {
"Caption Figure": "figure",
"Название таблицы": "table",
"Caption Table": "table",
"Caption Listing": "listing",
"Caption": None, # need text heuristic
}
HEADING_STYLE_RE = re.compile(r"^(?:Heading|Заголовок)\s*(\d+)$", re.IGNORECASE)
TOC_STYLE_RE = re.compile(r"^toc\s*\d+$", re.IGNORECASE)
SPECIAL_UPPER = {t.upper() for t in SPECIAL_TITLES}
APPENDIX_RE = re.compile(
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$",
re.IGNORECASE,
)
NUMBERED_HEADING_RE = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(.+)$")
PRACTICE_RE = re.compile(r"^Практическая\s+работа\b", re.IGNORECASE)
LIST_MARKER_RE = re.compile(r"^(?:[–—\-•▪]|(\d+)\.)\s*\t?\s*(.*)$")
BIBLIO_LINE_RE = re.compile(r"^\[(\d+(?:\.\d+)?)\]\s*[:.]?\s*(.+)$")
BODY_START_TITLES = SPECIAL_UPPER | {"СОДЕРЖАНИЕ"}
def style_name(paragraph) -> str:
try:
if paragraph.style is None:
return ""
return paragraph.style.name or ""
except Exception:
return ""
def is_toc_style(name: str) -> bool:
return bool(TOC_STYLE_RE.match((name or "").strip()))
def heading_level_from_style(name: str) -> int | None:
m = HEADING_STYLE_RE.match((name or "").strip())
if not m:
return None
try:
level = int(m.group(1))
except ValueError:
return None
return level if 1 <= level <= 9 else None
def is_code_style(name: str) -> bool:
n = (name or "").strip().lower()
return n == "code" or n.startswith("code ")
def is_bibliography_style(name: str) -> bool:
n = (name or "").strip().lower()
return n in ("bibliography", "bibliography heading")
def is_caption_style(name: str) -> bool:
return (name or "").strip() in CAPTION_STYLE_KINDS
def caption_kind_from_style(name: str) -> str | None:
return CAPTION_STYLE_KINDS.get((name or "").strip())
def is_special_title(text: str) -> bool:
t = (text or "").strip().upper()
if t in SPECIAL_UPPER:
return True
# allow «СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ» variants already in set
if t.startswith("СПИСОК ИСПОЛЬЗ"):
return True
return False
def is_body_start_heading(text: str, style: str = "") -> bool:
"""True if this heading marks the start of report body (skip title pages before it)."""
t = (text or "").strip()
if not t:
return False
if heading_level_from_style(style) or style.lower().startswith("heading"):
upper = t.upper()
if upper.lstrip("* ").upper() in BODY_START_TITLES or is_special_title(t):
return True
if PRACTICE_RE.match(t):
return True
if NUMBERED_HEADING_RE.match(t):
return True
if APPENDIX_RE.match(t):
return True
# Any Heading 1 after front matter
if heading_level_from_style(style) == 1:
return True
# Heuristic without style: special titles alone
if is_special_title(t) or PRACTICE_RE.match(t):
return True
return False
def classify_heading_text(text: str, style_level: int | None) -> tuple[int, str, bool]:
"""
Return (level, title_text, numbered).
numbered=False → emit as # *TITLE
"""
raw = (text or "").strip()
level = style_level or 1
app = APPENDIX_RE.match(raw)
if app:
letter = app.group(1).upper()
name = (app.group(2) or "").strip()
title = f"Приложение {letter}" + (f" {name}" if name else "")
return 2, title, True
if is_special_title(raw):
return 1, raw.upper(), False
m = NUMBERED_HEADING_RE.match(raw)
if m:
# keep digits in text for manual numbering dialect
return level, raw, True
if PRACTICE_RE.match(raw):
return 1, raw, True
return level, raw, True
def look_like_list_item(text: str) -> tuple[bool, bool, str] | None:
"""
Detect md2gost-style list lines (\\t / 1.\\t) or plain markers.
Returns (ordered, marker_ok, body) or None.
"""
t = (text or "").strip()
if not t:
return None
if t[0] in ("", "", "-", "", "") and (len(t) == 1 or t[1] in "\t "):
body = t[1:].lstrip("\t ").strip()
return False, True, body
m = re.match(r"^(\d+)\.\s*\t?\s*(.+)$", t)
if m:
return True, True, m.group(2).strip()
return None
def parse_biblio_line(text: str) -> tuple[str, str] | None:
m = BIBLIO_LINE_RE.match((text or "").strip())
if not m:
return None
return m.group(1), m.group(2).strip()
def paragraph_has_numpr(paragraph) -> bool:
pPr = paragraph._element.pPr
if pPr is None:
return False
return pPr.numPr is not None
+249
View File
@@ -0,0 +1,249 @@
"""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"
+91
View File
@@ -0,0 +1,91 @@
"""Extract images from a DOCX package into a media folder."""
from __future__ import annotations
import hashlib
import os
import zipfile
from pathlib import Path
from docx.oxml.ns import qn
def _r_id_from_blip(blip) -> str | None:
if blip is None:
return None
return blip.get(qn("r:embed")) or blip.get(
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
)
def iter_blip_rids(paragraph_element) -> list[str]:
"""Collect relationship ids for drawings in a paragraph."""
if paragraph_element is None:
return []
rids: list[str] = []
for blip in paragraph_element.findall(".//" + qn("a:blip")):
rid = _r_id_from_blip(blip)
if rid:
rids.append(rid)
return rids
class MediaExtractor:
"""Copy word/media parts referenced by rIds into dest_dir."""
def __init__(self, docx_path: str | Path, dest_dir: str | Path, *, rel_prefix: str):
self.docx_path = Path(docx_path)
self.dest_dir = Path(dest_dir)
self.rel_prefix = rel_prefix.replace("\\", "/").rstrip("/")
self.dest_dir.mkdir(parents=True, exist_ok=True)
self._zf = zipfile.ZipFile(self.docx_path, "r")
self._cache: dict[str, str] = {} # rid -> relative path for md
self._used_names: set[str] = set()
def close(self) -> None:
self._zf.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def extract_rid(self, document_part, rid: str) -> str | None:
"""Return path relative to the .md file, or None on failure."""
if rid in self._cache:
return self._cache[rid]
try:
rel = document_part.rels[rid]
except KeyError:
return None
target = getattr(rel, "target_ref", None) or getattr(rel, "target_part", None)
if target is None:
return None
# target_ref like "media/image1.png"
if hasattr(rel, "target_part"):
part = rel.target_part
blob = part.blob
name = os.path.basename(part.partname)
else:
ref = str(target).lstrip("/")
zip_name = "word/" + ref if not ref.startswith("word/") else ref
try:
blob = self._zf.read(zip_name)
except KeyError:
return None
name = os.path.basename(ref)
stem, ext = os.path.splitext(name)
if not ext:
ext = ".png"
safe = f"{stem}{ext}"
if safe in self._used_names:
digest = hashlib.md5(blob).hexdigest()[:8]
safe = f"{stem}_{digest}{ext}"
self._used_names.add(safe)
out_path = self.dest_dir / safe
out_path.write_bytes(blob)
rel_path = f"{self.rel_prefix}/{safe}" if self.rel_prefix else safe
self._cache[rid] = rel_path
return rel_path
+149
View File
@@ -0,0 +1,149 @@
"""Best-effort OMML → readable LaTeX-like string for $$ / $ fences."""
from __future__ import annotations
_OMML_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
def _local(tag: str) -> str:
if "}" in tag:
return tag.rsplit("}", 1)[-1]
return tag
def omml_to_latex(element) -> str:
"""Flatten OMML tree to a readable formula string (not perfect LaTeX)."""
if element is None:
return ""
parts: list[str] = []
_walk(element, parts)
text = "".join(parts)
text = " ".join(text.split())
return text.strip()
def _walk(node, parts: list[str]) -> None:
tag = _local(node.tag)
if tag == "t":
parts.append(node.text or "")
return
if tag == "f": # fraction
num = _child(node, "num")
den = _child(node, "den")
parts.append(r"\frac{")
if num is not None:
_walk(num, parts)
parts.append("}{")
if den is not None:
_walk(den, parts)
parts.append("}")
return
if tag == "sSup":
base = _child(node, "e")
sup = _child(node, "sup")
if base is not None:
_walk(base, parts)
parts.append("^{")
if sup is not None:
_walk(sup, parts)
parts.append("}")
return
if tag == "sSub":
base = _child(node, "e")
sub = _child(node, "sub")
if base is not None:
_walk(base, parts)
parts.append("_{")
if sub is not None:
_walk(sub, parts)
parts.append("}")
return
if tag == "sSubSup":
base = _child(node, "e")
sub = _child(node, "sub")
sup = _child(node, "sup")
if base is not None:
_walk(base, parts)
parts.append("_{")
if sub is not None:
_walk(sub, parts)
parts.append("}^{")
if sup is not None:
_walk(sup, parts)
parts.append("}")
return
if tag == "rad":
deg = _child(node, "deg")
e = _child(node, "e")
if deg is not None and "".join(deg.itertext()).strip():
parts.append(r"\sqrt[")
_walk(deg, parts)
parts.append("]{")
else:
parts.append(r"\sqrt{")
if e is not None:
_walk(e, parts)
parts.append("}")
return
if tag == "nary":
for child in node:
_walk(child, parts)
return
if tag in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub", "deg",
"fPr", "ctrlPr", "rPr", "sSupPr", "sSubPr", "sSubSupPr", "radPr",
"naryPr", "dPr", "boxPr", "argSz"):
for child in node:
_walk(child, parts)
return
if tag == "r":
for child in node:
_walk(child, parts)
return
if tag == "d": # delimiter
parts.append("(")
for child in node:
if _local(child.tag) != "dPr":
_walk(child, parts)
parts.append(")")
return
if tag in ("pPr", "rFonts", "sz", "szCs", "color", "jc"):
return
for child in node:
_walk(child, parts)
if node.tail:
parts.append(node.tail)
def _child(node, local_name: str):
for child in node:
if _local(child.tag) == local_name:
return child
return None
def find_omml(paragraph_element) -> list:
"""Return all m:oMath / m:oMathPara elements under a paragraph."""
if paragraph_element is None:
return []
found = []
for el in paragraph_element.iter():
tag = _local(el.tag)
if tag in ("oMath", "oMathPara"):
found.append(el)
return found
def paragraph_has_omml(paragraph_element) -> bool:
return bool(find_omml(paragraph_element))
+110
View File
@@ -0,0 +1,110 @@
"""DOCX → md2gost Markdown pipeline."""
from __future__ import annotations
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from docx import Document
from .emit import emit_markdown
from .media import MediaExtractor
from .refs import apply_reference_pass
from .walker import DocxWalker, postprocess_blocks
LogFn = Callable[[str], None]
@dataclass
class ImportRequest:
filename: str = ""
output: str | None = None
media_dir: str | None = None
keep_toc_pages: bool = False
emit_page_breaks: bool = False
@dataclass
class ImportResult:
ok: bool
exit_code: int = 0
output_path: str | None = None
media_dir: str | None = None
message: str = ""
def default_output_path(docx_path: str | Path) -> Path:
p = Path(docx_path)
return p.with_suffix(".md")
def default_media_dir(md_path: Path) -> Path:
return md_path.parent / f"{md_path.stem}_media"
def convert_docx(req: ImportRequest, log: LogFn | None = None) -> ImportResult:
"""Convert DOCX to md2gost dialect Markdown. Errors go into ImportResult."""
emit: LogFn = log if log is not None else print
def fail(code: int, message: str) -> ImportResult:
emit(message)
return ImportResult(False, code, message=message)
filename = (req.filename or "").strip()
if not filename:
return fail(2, "Укажите исходный .docx")
path = Path(filename)
if not path.is_file():
return fail(2, f"Файл не найден: {path}")
if path.suffix.lower() != ".docx":
return fail(1, "Исходный файл должен быть в формате .docx")
out = Path(req.output) if req.output else default_output_path(path)
if out.suffix.lower() != ".md":
out = out.with_suffix(".md")
out.parent.mkdir(parents=True, exist_ok=True)
media_path = Path(req.media_dir) if req.media_dir else default_media_dir(out)
rel_prefix = media_path.name
# If media dir is not sibling of md, use relative path from md parent
try:
rel_prefix = str(media_path.resolve().relative_to(out.parent.resolve())).replace("\\", "/")
except ValueError:
rel_prefix = str(media_path).replace("\\", "/")
try:
document = Document(str(path))
except Exception as exc:
return fail(1, f"Не удалось открыть DOCX: {exc}")
try:
with MediaExtractor(path, media_path, rel_prefix=rel_prefix) as media:
walker = DocxWalker(
document,
media,
keep_toc_pages=req.keep_toc_pages,
emit_page_breaks=req.emit_page_breaks,
log=emit,
)
blocks = walker.walk()
blocks = postprocess_blocks(blocks)
blocks = apply_reference_pass(blocks)
md_text = emit_markdown(blocks, emit_page_breaks=req.emit_page_breaks)
out.write_text(md_text, encoding="utf-8")
except Exception as exc:
emit(traceback.format_exc())
return fail(1, f"Ошибка импорта: {exc}")
msg = f"Markdown: {out}"
emit(msg)
if media_path.is_dir() and any(media_path.iterdir()):
emit(f"Медиа: {media_path}")
return ImportResult(
True,
0,
output_path=str(out.resolve()),
media_dir=str(media_path.resolve()) if media_path.exists() else None,
message=msg,
)
+170
View File
@@ -0,0 +1,170 @@
"""Second pass: replace «Рисунок 1.1» / «табл. 2» with @Тип:id."""
from __future__ import annotations
import re
from .blocks import (
Block,
CaptionBlock,
EquationBlock,
ImageBlock,
InlineSpan,
ListBlock,
ListingBlock,
ParagraphBlock,
TableBlock,
)
from .captions import caption_unique_id
# Number like 1 / 1.1 / 2.3.4 — do NOT use [\d.]+ (eats trailing sentence period).
_NUM = r"(?P<num>\d+(?:\.\d+)*)"
_REF_PATTERNS = [
(
re.compile(
rf"(?P<full>(?:Рисунк(?:е|а|у|ом)?|Рисунок|Рис\.?)\s+{_NUM})",
re.IGNORECASE,
),
"figure",
),
(
re.compile(
rf"(?P<full>(?:Таблиц[аыеу]|табл\.?)\s+{_NUM})",
re.IGNORECASE,
),
"table",
),
(
re.compile(
rf"(?P<full>(?:Листинг(?:а|у|е|ом)?|лист\.?)\s+{_NUM})",
re.IGNORECASE,
),
"listing",
),
(
re.compile(
rf"(?P<full>(?:Формул[аыеу]|форм\.?)\s+{_NUM})",
re.IGNORECASE,
),
"equation",
),
]
_TYPE_LABEL = {
"figure": "Рисунок",
"table": "Таблица",
"listing": "Листинг",
"equation": "Формула",
}
def collect_number_maps(blocks: list[Block]) -> dict[str, dict[str, str]]:
"""kind → {number → unique_id}."""
maps: dict[str, dict[str, str]] = {
"figure": {},
"table": {},
"listing": {},
"equation": {},
}
for b in blocks:
if isinstance(b, CaptionBlock) and not b.is_continuation:
maps[b.kind][b.number] = b.unique_id or caption_unique_id(b.kind, b.number)
elif isinstance(b, ImageBlock) and b.caption_id:
num = _id_to_number(b.caption_id, "fig")
if num:
maps["figure"][num] = b.caption_id
elif isinstance(b, TableBlock) and b.caption_id:
num = _id_to_number(b.caption_id, "tbl")
if num:
maps["table"][num] = b.caption_id
elif isinstance(b, ListingBlock) and b.caption_id:
num = _id_to_number(b.caption_id, "lst")
if num:
maps["listing"][num] = b.caption_id
elif isinstance(b, EquationBlock) and b.number and b.unique_id:
maps["equation"][b.number] = b.unique_id
return maps
def _id_to_number(uid: str, prefix: str) -> str | None:
if not uid.startswith(prefix):
return None
rest = uid[len(prefix):]
if not rest:
return None
return rest.replace("_", ".")
def rewrite_text(text: str, maps: dict[str, dict[str, str]]) -> str:
"""Replace object references; leave bibliography [1] alone."""
if not text:
return text
def repl_factory(kind: str):
label = _TYPE_LABEL[kind]
num_map = maps.get(kind) or {}
def repl(m: re.Match) -> str:
num = m.group("num")
uid = num_map.get(num)
if not uid:
return m.group(0)
return f"@{label}:{uid}"
return repl
out = text
for pattern, kind in _REF_PATTERNS:
out = pattern.sub(repl_factory(kind), out)
return out
def _rewrite_paragraph_spans(para: ParagraphBlock, maps: dict[str, dict[str, str]]) -> None:
"""Rewrite refs even when Word split «Таблице 5.1» across runs."""
spans = para.spans
if not spans:
return
new_spans: list[InlineSpan] = []
i = 0
while i < len(spans):
s = spans[i]
if s.math or s.href:
new_spans.append(s)
i += 1
continue
j = i
chunk: list[InlineSpan] = []
while j < len(spans) and not spans[j].math and not spans[j].href:
chunk.append(spans[j])
j += 1
joined = "".join(p.text or "" for p in chunk)
rewritten = rewrite_text(joined, maps)
if rewritten == joined:
new_spans.extend(chunk)
else:
first = chunk[0]
same_fmt = all(
p.bold == first.bold and p.italic == first.italic and p.strike == first.strike
for p in chunk
)
new_spans.append(
InlineSpan(
text=rewritten,
bold=first.bold if same_fmt else False,
italic=first.italic if same_fmt else False,
strike=first.strike if same_fmt else False,
)
)
i = j
para.spans = new_spans
def apply_reference_pass(blocks: list[Block]) -> list[Block]:
maps = collect_number_maps(blocks)
for b in blocks:
if isinstance(b, ParagraphBlock):
_rewrite_paragraph_spans(b, maps)
elif isinstance(b, ListBlock):
b.items = [rewrite_text(item, maps) for item in b.items]
return blocks
+140
View File
@@ -0,0 +1,140 @@
"""DOCX table → markdown rows with ^ / > merge markers."""
from __future__ import annotations
from docx.oxml.ns import qn
from docx.table import Table as DocxTable
def _cell_v_merge(tc) -> str:
"""Return none | restart | continue for vertical merge."""
tcPr = tc.tcPr
if tcPr is None:
return "none"
vMerge = tcPr.find(qn("w:vMerge"))
if vMerge is None:
return "none"
val = vMerge.get(qn("w:val"))
if val in (None, "continue"):
return "continue"
return "restart"
def _cell_grid_span(tc) -> int:
tcPr = tc.tcPr
if tcPr is None:
return 1
span = tcPr.find(qn("w:gridSpan"))
if span is None:
return 1
try:
return max(1, int(span.get(qn("w:val")) or "1"))
except ValueError:
return 1
def _cell_text(cell) -> str:
from .text import paragraph_plain
parts: list[str] = []
for p in cell.paragraphs:
t = paragraph_plain(p)
if t:
parts.append(t)
return " ".join(parts).replace("|", "\\|")
def table_to_grid(docx_table: DocxTable) -> tuple[list[list[str]], list[list[tuple[str, str]]]]:
"""
Expand a Word table into a rectangular grid.
merges[r][c] = (v, h) where v/h in {none, restart, continue}.
Horizontally spanned continue cells are filled with '>' semantics.
Vertically continued cells use '^'.
"""
rows_xml = docx_table._tbl.tr_lst
# First pass: determine max columns from grid spans
max_cols = 0
row_infos: list[list[tuple[object, int, str]]] = [] # (tc, span, vmerge)
for tr in rows_xml:
info: list[tuple[object, int, str]] = []
col = 0
for tc in tr.tc_lst:
span = _cell_grid_span(tc)
v = _cell_v_merge(tc)
info.append((tc, span, v))
col += span
max_cols = max(max_cols, col)
row_infos.append(info)
if max_cols == 0:
return [], []
texts: list[list[str]] = [["-" for _ in range(max_cols)] for _ in range(len(row_infos))]
merges: list[list[tuple[str, str]]] = [
[("none", "none") for _ in range(max_cols)] for _ in range(len(row_infos))
]
# Map tc elements to python-docx cells by index
for r, info in enumerate(row_infos):
c = 0
cell_idx = 0
for tc, span, v in info:
try:
cell = docx_table.rows[r].cells[cell_idx]
text = _cell_text(cell)
except Exception:
text = ""
cell_idx += 1
if v == "continue":
merges[r][c] = ("continue", "none")
texts[r][c] = "^"
else:
merges[r][c] = ("restart" if v == "restart" else "none", "none" if span == 1 else "restart")
texts[r][c] = text if text else " "
for k in range(1, span):
if c + k < max_cols:
merges[r][c + k] = ("none", "continue")
texts[r][c + k] = ">"
c += span
return texts, merges
def is_formula_table(docx_table: DocxTable) -> bool:
"""Heuristic: 1×2 table with Formula Content / Formula Numbering styles."""
try:
if len(docx_table.rows) != 1 or len(docx_table.columns) != 2:
return False
except Exception:
return False
styles: list[str] = []
for cell in docx_table.rows[0].cells:
for p in cell.paragraphs:
name = (p.style.name if p.style is not None else "") or ""
styles.append(name)
joined = " ".join(styles).lower()
return "formula" in joined
def formula_table_parts(docx_table: DocxTable) -> tuple[object | None, str | None]:
"""Return (omml_element_or_None, number_text_or_None) from a formula table."""
from .omml import find_omml, omml_to_latex
from .text import paragraph_plain
left = docx_table.rows[0].cells[0]
right = docx_table.rows[0].cells[1]
latex = ""
for p in left.paragraphs:
for om in find_omml(p._element):
latex = omml_to_latex(om)
if latex:
break
if latex:
break
if not latex:
latex = " ".join(paragraph_plain(p) for p in left.paragraphs).strip()
number = " ".join(paragraph_plain(p) for p in right.paragraphs).strip()
number = number.strip("() ").strip() or None
return latex, number
+78
View File
@@ -0,0 +1,78 @@
"""Extract visible text from OOXML runs/paragraphs (incl. w:noBreakHyphen)."""
from __future__ import annotations
from docx.oxml.ns import qn
def _local(tag: str) -> str:
if "}" in tag:
return tag.rsplit("}", 1)[-1]
return tag
def run_element_text(run_el) -> str:
"""
Text of a w:r including non-breaking hyphens.
md2gost writes ASCII '-' as w:noBreakHyphen (python-docx run.text skips it).
Soft hyphens are optional break hints — omit them from Markdown.
"""
if run_el is None:
return ""
parts: list[str] = []
for child in run_el:
tag = _local(child.tag)
if tag == "t":
parts.append(child.text or "")
elif tag == "noBreakHyphen":
parts.append("-")
elif tag == "softHyphen":
continue
elif tag == "tab":
parts.append("\t")
elif tag == "br":
# page/line break inside run — treat as space for inline text
if child.get(qn("w:type")) == "page":
continue
parts.append("\n")
elif tag == "cr":
parts.append("\n")
return "".join(parts)
def hyperlink_element_text(hyperlink_el) -> str:
if hyperlink_el is None:
return ""
parts: list[str] = []
for run in hyperlink_el.findall(qn("w:r")):
parts.append(run_element_text(run))
return "".join(parts)
def paragraph_element_text(p_el) -> str:
"""Visible body text of w:p (runs + hyperlinks), without OMML."""
if p_el is None:
return ""
parts: list[str] = []
for child in p_el:
tag = child.tag
if tag == qn("w:r"):
parts.append(run_element_text(child))
elif tag == qn("w:hyperlink"):
parts.append(hyperlink_element_text(child))
return "".join(parts)
def paragraph_plain(paragraph) -> str:
"""Strip CR/bell from a python-docx Paragraph, keeping noBreakHyphen as '-'."""
return paragraph_raw(paragraph).strip()
def paragraph_raw(paragraph) -> str:
"""Full paragraph text with noBreakHyphen → '-', without strip (for Code lines)."""
try:
text = paragraph_element_text(paragraph._element)
except Exception:
text = paragraph.text or ""
return text.replace("\r", "").replace("\x07", "")
+596
View File
@@ -0,0 +1,596 @@
"""Walk DOCX body and produce IR blocks."""
from __future__ import annotations
import re
from typing import Callable
from docx import Document
from docx.oxml.ns import qn
from docx.table import Table as DocxTable
from docx.text.paragraph import Paragraph as DocxParagraph
from .blocks import (
Block,
CaptionBlock,
EquationBlock,
HeadingBlock,
ImageBlock,
InlineSpan,
ListBlock,
ListingBlock,
PageBreakBlock,
ParagraphBlock,
SkipNote,
TableBlock,
TocPlaceholder,
)
from .captions import caption_unique_id, parse_any_caption
from .classify import (
caption_kind_from_style,
classify_heading_text,
heading_level_from_style,
is_bibliography_style,
is_body_start_heading,
is_caption_style,
is_code_style,
is_special_title,
is_toc_style,
look_like_list_item,
parse_biblio_line,
paragraph_has_numpr,
style_name,
)
from .media import MediaExtractor, iter_blip_rids
from .omml import find_omml, omml_to_latex, paragraph_has_omml
from .tables import formula_table_parts, is_formula_table, table_to_grid
from .text import (
hyperlink_element_text,
paragraph_plain,
paragraph_raw,
run_element_text,
)
LogFn = Callable[[str], None]
def _para_plain(paragraph: DocxParagraph) -> str:
return paragraph_plain(paragraph)
def _iter_body_items(document: Document):
body = document.element.body
for child in body.iterchildren():
tag = child.tag
if tag == qn("w:p"):
yield "p", DocxParagraph(child, document)
elif tag == qn("w:tbl"):
yield "tbl", DocxTable(child, document)
elif tag == qn("w:sectPr"):
continue
else:
yield "other", child
def _inline_spans(paragraph: DocxParagraph) -> list[InlineSpan]:
"""Build inline spans from runs + hyperlinks + inline math."""
spans: list[InlineSpan] = []
p = paragraph._element
for child in p:
tag = child.tag
if tag == qn("w:r"):
text = run_element_text(child)
if child.find(qn("w:br")) is not None and not text.strip():
# lone page/line break run
continue
if not text:
continue
rPr = child.find(qn("w:rPr"))
bold = italic = strike = False
if rPr is not None:
bold = rPr.find(qn("w:b")) is not None
italic = rPr.find(qn("w:i")) is not None
strike = rPr.find(qn("w:strike")) is not None or rPr.find(qn("w:dstrike")) is not None
spans.append(InlineSpan(text=text, bold=bold, italic=italic, strike=strike))
elif tag == qn("w:hyperlink"):
anchor = child.get(qn("w:anchor"))
rid = child.get(qn("r:id"))
href = None
if rid:
try:
rel = paragraph.part.rels[rid]
href = rel.target_ref
except Exception:
href = None
inner = hyperlink_element_text(child)
if not inner:
continue
# Internal biblio links → keep plain [n] text (no md link)
if anchor and str(anchor).startswith("biblio_"):
spans.append(InlineSpan(text=inner))
elif href and not str(href).startswith("#"):
spans.append(InlineSpan(text=inner, href=href))
else:
spans.append(InlineSpan(text=inner))
elif tag.endswith("}oMath") or tag.endswith("}oMathPara"):
latex = omml_to_latex(child)
if latex:
spans.append(InlineSpan(text="", math=latex))
# Fallback if empty but paragraph has text
if not spans:
plain = _para_plain(paragraph)
if plain:
spans.append(InlineSpan(text=plain))
return spans
def _section_landscape(document: Document, element) -> bool:
"""Best-effort: check nearest following sectPr or document default."""
try:
# Walk forward siblings for sectPr
parent = element.getparent()
if parent is None:
return False
el = element.getnext()
while el is not None:
if el.tag == qn("w:sectPr"):
pgSz = el.find(qn("w:pgSz"))
if pgSz is not None:
w = int(pgSz.get(qn("w:w") or "0") or 0)
h = int(pgSz.get(qn("w:h") or "0") or 0)
orient = pgSz.get(qn("w:orient"))
if orient == "landscape" or (w and h and w > h):
return True
return False
el = el.getnext()
# last section
sect = document.sections[-1]
return bool(sect.page_width and sect.page_height and sect.page_width > sect.page_height)
except Exception:
return False
class DocxWalker:
def __init__(
self,
document: Document,
media: MediaExtractor,
*,
keep_toc_pages: bool = False,
emit_page_breaks: bool = False,
log: LogFn | None = None,
):
self.document = document
self.media = media
self.keep_toc_pages = keep_toc_pages
self.emit_page_breaks = emit_page_breaks
self.log = log or (lambda _m: None)
self._started = False
self._saw_toc_heading = False
self._pending_caption: CaptionBlock | None = None
self._code_buf: list[str] = []
self._code_caption: CaptionBlock | None = None
self._list_buf: list[str] | None = None
self._list_ordered: bool | None = None
def walk(self) -> list[Block]:
blocks: list[Block] = []
for kind, item in _iter_body_items(self.document):
if kind == "p":
blocks.extend(self._handle_paragraph(item))
elif kind == "tbl":
self._flush_code(blocks)
self._flush_list(blocks)
blocks.extend(self._handle_table(item))
else:
self.log(f"Пропуск неизвестного элемента body: {getattr(item, 'tag', item)}")
blocks.append(SkipNote(message=f"unknown body child {getattr(item, 'tag', '')}"))
self._flush_code(blocks)
self._flush_list(blocks)
return [b for b in blocks if not isinstance(b, SkipNote)]
def _flush_code(self, blocks: list[Block]) -> None:
if not self._code_buf:
self._code_caption = None
return
cap = self._code_caption
uid = cap.unique_id if cap else None
text = cap.text if cap else None
blocks.append(
ListingBlock(
lines=list(self._code_buf),
language="",
caption_id=uid,
caption_text=text,
)
)
self._code_buf.clear()
self._code_caption = None
self._pending_caption = None
def _flush_list(self, blocks: list[Block]) -> None:
if self._list_buf is None:
return
blocks.append(
ListBlock(
ordered=bool(self._list_ordered),
items=list(self._list_buf),
)
)
self._list_buf = None
self._list_ordered = None
def _handle_paragraph(self, paragraph: DocxParagraph) -> list[Block]:
out: list[Block] = []
style = style_name(paragraph)
text = _para_plain(paragraph)
el = paragraph._element
# Page break
if self.emit_page_breaks:
for br in el.findall(".//" + qn("w:br")):
if br.get(qn("w:type")) == "page":
self._flush_code(out)
self._flush_list(out)
out.append(PageBreakBlock())
# Skip until body start (title / assignment)
if not self._started:
if is_body_start_heading(text, style) or heading_level_from_style(style):
if is_body_start_heading(text, style) or heading_level_from_style(style) == 1:
self._started = True
elif not text:
return out
else:
return out
else:
return out
# TOC field lines
if is_toc_style(style):
if not self.keep_toc_pages:
return out
# keep as plain paragraphs if requested
self._flush_code(out)
self._flush_list(out)
out.append(ParagraphBlock(spans=_inline_spans(paragraph), style=style))
return out
# Native TOC field detection
if self._has_toc_field(el):
self._flush_code(out)
self._flush_list(out)
if not self.keep_toc_pages:
# only placeholder once after СОДЕРЖАНИЕ heading
return out
return out
level = heading_level_from_style(style)
if level is not None or (text and is_special_title(text) and not text.startswith("[")):
self._flush_code(out)
self._flush_list(out)
if not text:
return out
lvl, title, numbered = classify_heading_text(text, level or 1)
if title.upper() == "СОДЕРЖАНИЕ" or title.upper().startswith("СОДЕРЖАНИЕ"):
out.append(HeadingBlock(level=1, text="СОДЕРЖАНИЕ", numbered=False))
out.append(TocPlaceholder())
self._saw_toc_heading = True
self._pending_caption = None
return out
out.append(HeadingBlock(level=lvl, text=title, numbered=numbered))
self._pending_caption = None
return out
# Caption styles / heuristic captions
if is_caption_style(style) or parse_any_caption(text):
parsed = parse_any_caption(text)
kind = caption_kind_from_style(style) if is_caption_style(style) else None
if parsed is None and kind:
# style known but text odd — treat whole text as title
parsed = parse_any_caption(text) # may still fail
if parsed is not None:
self._flush_code(out)
self._flush_list(out)
if kind and parsed.kind != kind and kind != "figure":
# prefer text kind when style is generic Caption
pass
uid = caption_unique_id(parsed.kind, parsed.number)
cap = CaptionBlock(
kind=parsed.kind,
number=parsed.number,
text=parsed.text,
is_continuation=parsed.is_continuation,
unique_id=uid,
)
if parsed.is_continuation:
# Emit marker for postprocess_blocks to splice into previous table/listing.
# Do NOT stash in _pending_caption — that created a second %tbl / table.
self._pending_caption = None
out.append(cap)
return out
# Tables/listings: caption precedes object → keep pending.
# Figures (GOST): caption is under the picture → only CaptionBlock;
# postprocess folds Image+Caption. Stashing pending would glue this
# caption onto the *next* figure.
if parsed.kind in ("table", "listing"):
self._pending_caption = cap
else:
self._pending_caption = None
out.append(cap)
return out
# Code / listing lines
if is_code_style(style):
self._flush_list(out)
if self._pending_caption and self._pending_caption.kind == "listing":
self._code_caption = self._pending_caption
self._pending_caption = None
self._code_buf.append(paragraph_raw(paragraph))
return out
# Images in paragraph
rids = iter_blip_rids(el)
if rids:
self._flush_code(out)
self._flush_list(out)
# Caption-before-image is rare; GOST puts caption under the picture
# (folded later). Only consume pending if it is explicitly a figure.
cap = self._pending_caption
cid = text_c = None
if cap and cap.kind == "figure":
cid = cap.unique_id
text_c = cap.text
self._pending_caption = None
for rid in rids:
rel = self.media.extract_rid(paragraph.part, rid)
if not rel:
self.log(f"Не удалось извлечь картинку {rid}")
continue
out.append(
ImageBlock(
rel_path=rel,
alt=text or "",
caption_id=cid,
caption_text=text_c,
)
)
cid = text_c = None # only first drawing gets pre-caption
return out
# Bibliography
if is_bibliography_style(style) or parse_biblio_line(text):
self._flush_code(out)
self._flush_list(out)
biblio = parse_biblio_line(text)
if biblio:
key, body = biblio
out.append(
ParagraphBlock(
spans=[InlineSpan(text=f"[{key}]: {body}")],
style=style,
is_bibliography=True,
biblio_key=key,
)
)
return out
# Lists (Word numPr or md2gost markers)
if paragraph_has_numpr(paragraph):
self._flush_code(out)
body = text
# strip auto number text if present at start — keep as item body
ordered = True
try:
# python-docx doesn't expose easily; assume numbered if numPr
m = re.match(r"^\d+\.\s*(.*)$", text)
if m:
body = m.group(1)
else:
# bullet
ordered = False
body = re.sub(r"^[–—\-•▪]\s*", "", text)
except Exception:
pass
if self._list_buf is not None and self._list_ordered == ordered:
self._list_buf.append(body)
else:
self._flush_list(out)
self._list_buf = [body]
self._list_ordered = ordered
return []
list_hit = look_like_list_item(text)
if list_hit and (paragraph.paragraph_format.left_indent or "\t" in (paragraph.text or "")):
ordered, _ok, body = list_hit
self._flush_code(out)
if self._list_buf is not None and self._list_ordered == ordered:
self._list_buf.append(body)
else:
self._flush_list(out)
self._list_buf = [body]
self._list_ordered = ordered
return []
# Plain paragraph (maybe with inline math)
self._flush_code(out)
self._flush_list(out)
if not text and not paragraph_has_omml(el):
return out
spans = _inline_spans(paragraph)
# If whole paragraph is only omml (block-ish), emit equation
oms = find_omml(el)
if oms and not text.replace(" ", ""):
latex = omml_to_latex(oms[0])
if latex:
out.append(EquationBlock(latex=latex))
return out
out.append(ParagraphBlock(spans=spans, style=style))
self._pending_caption = None
return out
def _handle_table(self, table: DocxTable) -> list[Block]:
out: list[Block] = []
if not self._started:
# title-page layout tables — skip
return out
if is_formula_table(table):
latex, number = formula_table_parts(table)
uid = None
if number:
uid = caption_unique_id("figure", number).replace("fig", "eq")
# better: eq + number
uid = "eq" + str(number).replace(".", "_")
out.append(EquationBlock(latex=latex or "?", number=number, unique_id=uid))
self._pending_caption = None
return out
landscape = _section_landscape(self.document, table._tbl)
rows, merges = table_to_grid(table)
if not rows:
return out
cap = self._pending_caption
cid = text_c = None
if cap and cap.kind == "table" and not cap.is_continuation:
cid = cap.unique_id
text_c = cap.text
landscape = landscape or cap.landscape
self._pending_caption = None
elif cap and cap.is_continuation:
self._pending_caption = None
out.append(
TableBlock(
rows=rows,
merges=merges,
caption_id=cid,
caption_text=text_c,
landscape=landscape,
)
)
return out
@staticmethod
def _has_toc_field(p_element) -> bool:
for instr in p_element.findall(".//" + qn("w:instrText")):
raw = (instr.text or "").upper()
if "TOC" in raw:
return True
return False
def postprocess_blocks(blocks: list[Block]) -> list[Block]:
"""Merge continuation tables/listings; fold figure captions under images; drop dup TOC."""
result: list[Block] = []
saw_toc = False
i = 0
while i < len(blocks):
b = blocks[i]
if isinstance(b, TocPlaceholder):
if saw_toc:
i += 1
continue
saw_toc = True
result.append(b)
i += 1
continue
if isinstance(b, CaptionBlock) and b.is_continuation:
kind = b.kind
i += 1
while i < len(blocks) and isinstance(blocks[i], SkipNote):
i += 1
if i >= len(blocks):
break
nxt = blocks[i]
prev = _last_block_of_type(
result,
TableBlock if kind == "table" else ListingBlock if kind == "listing" else None,
)
if kind == "table" and isinstance(nxt, TableBlock) and isinstance(prev, TableBlock):
_merge_table_continuation(prev, nxt)
i += 1
continue
if kind == "listing" and isinstance(nxt, ListingBlock) and isinstance(prev, ListingBlock):
prev.lines.extend(nxt.lines)
i += 1
continue
result.append(nxt)
i += 1
continue
if isinstance(b, CaptionBlock) and not b.is_continuation:
result.append(b)
i += 1
continue
result.append(b)
i += 1
return _fold_figure_captions_after_images(result)
def _fold_figure_captions_after_images(blocks: list[Block]) -> list[Block]:
"""
In DOCX/GOST the caption is under the picture; in md2gost %id / title must
precede the image. Fold «Image + Caption(figure)» into one ImageBlock.
"""
out: list[Block] = []
i = 0
while i < len(blocks):
b = blocks[i]
nxt = blocks[i + 1] if i + 1 < len(blocks) else None
if (
isinstance(b, ImageBlock)
and 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
out.append(b)
i += 2
continue
# Drop a lone figure caption that duplicates the previous image's id
if (
isinstance(b, CaptionBlock)
and b.kind == "figure"
and not b.is_continuation
and out
and isinstance(out[-1], ImageBlock)
and out[-1].caption_id == b.unique_id
):
if not out[-1].caption_text and b.text:
out[-1].caption_text = b.text
i += 1
continue
out.append(b)
i += 1
return out
def _last_block_of_type(blocks: list[Block], typ) -> Block | None:
if typ is None:
return None
for b in reversed(blocks):
if isinstance(b, typ):
return b
return None
def _merge_table_continuation(prev: TableBlock, nxt: TableBlock) -> None:
"""Append continuation rows; drop a repeated header row if Word duplicated it."""
rows = list(nxt.rows or [])
merges = list(nxt.merges) if nxt.merges is not None else None
if prev.rows and rows and prev.rows[0] == rows[0]:
rows = rows[1:]
if merges is not None:
merges = merges[1:]
prev.rows.extend(rows)
if prev.merges is not None and merges is not None:
prev.merges.extend(merges)
elif prev.merges is None and merges is not None and prev.rows:
# keep merges only if both sides had them; otherwise drop
pass