@@ -0,0 +1,9 @@
|
||||
# word2md
|
||||
|
||||
Импорт **DOCX → Markdown** в диалекте md2gost.
|
||||
|
||||
```bash
|
||||
python -m word2md report.docx -o report.md
|
||||
```
|
||||
|
||||
Документация: [`docs/word2md.md`](../docs/word2md.md).
|
||||
@@ -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__"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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"")
|
||||
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"
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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", "")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user