628 lines
21 KiB
Python
628 lines
21 KiB
Python
"""Walk DOCX body and produce IR blocks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any, 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 (
|
|
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,
|
|
parse_biblio_line,
|
|
parse_list_item,
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Para:
|
|
"""The few paragraph facts every `_handle_paragraph` branch needs."""
|
|
|
|
paragraph: DocxParagraph
|
|
element: Any
|
|
style: str
|
|
text: str
|
|
|
|
|
|
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
|
|
self._list_levels: list[int] | 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),
|
|
item_levels=list(self._list_levels or []),
|
|
)
|
|
)
|
|
self._list_buf = None
|
|
self._list_ordered = None
|
|
self._list_levels = None
|
|
|
|
def _handle_paragraph(self, paragraph: DocxParagraph) -> list[Block]:
|
|
out: list[Block] = []
|
|
para = _Para(
|
|
paragraph=paragraph,
|
|
element=paragraph._element,
|
|
style=style_name(paragraph),
|
|
text=_para_plain(paragraph),
|
|
)
|
|
|
|
self._emit_page_breaks(para, out)
|
|
if not self._reached_body_start(para):
|
|
return out
|
|
|
|
for handler in (
|
|
self._as_toc_style,
|
|
self._as_toc_field,
|
|
self._as_heading,
|
|
self._as_caption,
|
|
self._as_code_line,
|
|
self._as_images,
|
|
self._as_bibliography,
|
|
self._as_numbered_list_item,
|
|
self._as_marker_list_item,
|
|
):
|
|
handled = handler(para, out)
|
|
if handled is not None:
|
|
return handled
|
|
|
|
return self._as_plain_paragraph(para, out)
|
|
|
|
def _emit_page_breaks(self, para: _Para, out: list[Block]) -> None:
|
|
if not self.emit_page_breaks:
|
|
return
|
|
for br in para.element.findall(".//" + qn("w:br")):
|
|
if br.get(qn("w:type")) == "page":
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
out.append(PageBreakBlock())
|
|
|
|
def _reached_body_start(self, para: _Para) -> bool:
|
|
"""Front matter (title page, assignment) is dropped until the first Heading 1."""
|
|
if self._started:
|
|
return True
|
|
if is_body_start_heading(para.text, para.style) or heading_level_from_style(para.style) == 1:
|
|
self._started = True
|
|
return True
|
|
return False
|
|
|
|
def _as_toc_style(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
if not is_toc_style(para.style):
|
|
return None
|
|
if not self.keep_toc_pages:
|
|
return out
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
out.append(ParagraphBlock(spans=_inline_spans(para.paragraph), style=para.style))
|
|
return out
|
|
|
|
def _as_toc_field(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
if not self._has_toc_field(para.element):
|
|
return None
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
return out
|
|
|
|
def _as_heading(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
level = heading_level_from_style(para.style)
|
|
is_heading = level is not None or (
|
|
para.text and is_special_title(para.text) and not para.text.startswith("[")
|
|
)
|
|
if not is_heading:
|
|
return None
|
|
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
if not para.text:
|
|
return out
|
|
|
|
lvl, title, numbered = classify_heading_text(para.text, level or 1)
|
|
self._pending_caption = None
|
|
if title.upper() == "СОДЕРЖАНИЕ" or title.upper().startswith("СОДЕРЖАНИЕ"):
|
|
out.append(HeadingBlock(level=1, text="СОДЕРЖАНИЕ", numbered=False))
|
|
out.append(TocPlaceholder())
|
|
self._saw_toc_heading = True
|
|
return out
|
|
out.append(HeadingBlock(level=lvl, text=title, numbered=numbered))
|
|
return out
|
|
|
|
def _as_caption(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
if not (is_caption_style(para.style) or parse_any_caption(para.text)):
|
|
return None
|
|
parsed = parse_any_caption(para.text)
|
|
if parsed is None:
|
|
return None
|
|
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
cap = CaptionBlock(
|
|
kind=parsed.kind,
|
|
number=parsed.number,
|
|
text=parsed.text,
|
|
is_continuation=parsed.is_continuation,
|
|
unique_id=caption_unique_id(parsed.kind, parsed.number),
|
|
)
|
|
# Tables/listings: caption precedes the object → keep pending.
|
|
# Continuations are spliced by postprocess_blocks; figures (GOST) are
|
|
# captioned *under* the picture and folded later. Stashing either would
|
|
# glue the caption onto the next object.
|
|
self._pending_caption = (
|
|
cap if not parsed.is_continuation and parsed.kind in ("table", "listing") else None
|
|
)
|
|
out.append(cap)
|
|
return out
|
|
|
|
def _as_code_line(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
if not is_code_style(para.style):
|
|
return None
|
|
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(para.paragraph))
|
|
return out
|
|
|
|
def _as_images(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
rids = iter_blip_rids(para.element)
|
|
if not rids:
|
|
return None
|
|
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(para.paragraph.part, rid)
|
|
if not rel:
|
|
self.log(f"Не удалось извлечь картинку {rid}")
|
|
continue
|
|
out.append(
|
|
ImageBlock(
|
|
rel_path=rel,
|
|
alt=para.text or "",
|
|
caption_id=cid,
|
|
caption_text=text_c,
|
|
)
|
|
)
|
|
cid = text_c = None # only first drawing gets pre-caption
|
|
return out
|
|
|
|
def _as_bibliography(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
biblio = parse_biblio_line(para.text)
|
|
if not (is_bibliography_style(para.style) or biblio):
|
|
return None
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
if not biblio:
|
|
return None # bibliography style but no [n] marker — treat as plain text
|
|
key, body = biblio
|
|
out.append(
|
|
ParagraphBlock(
|
|
spans=[InlineSpan(text=f"[{key}]: {body}")],
|
|
style=para.style,
|
|
is_bibliography=True,
|
|
biblio_key=key,
|
|
)
|
|
)
|
|
return out
|
|
|
|
def _as_numbered_list_item(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
if not paragraph_has_numpr(para.paragraph):
|
|
return None
|
|
self._flush_code(out)
|
|
# python-docx does not expose the rendered number, so infer from the text
|
|
parsed = parse_list_item(para.text)
|
|
if parsed is not None:
|
|
ordered, level, body = parsed
|
|
else:
|
|
ordered, level, body = False, 1, re.sub(r"^[–—\-•▪]\s*", "", para.text)
|
|
self._append_list_item(ordered, body, out, level)
|
|
return []
|
|
|
|
def _as_marker_list_item(self, para: _Para, out: list[Block]) -> list[Block] | None:
|
|
parsed = parse_list_item(para.text)
|
|
indented = para.paragraph.paragraph_format.left_indent or "\t" in (
|
|
para.paragraph.text or ""
|
|
)
|
|
if not (parsed and indented):
|
|
return None
|
|
ordered, level, body = parsed
|
|
self._flush_code(out)
|
|
self._append_list_item(ordered, body, out, level)
|
|
return []
|
|
|
|
def _append_list_item(
|
|
self, ordered: bool, body: str, out: list[Block], level: int = 1
|
|
) -> None:
|
|
if self._list_buf is not None and self._list_ordered == ordered:
|
|
self._list_buf.append(body)
|
|
self._list_levels.append(level)
|
|
return
|
|
self._flush_list(out)
|
|
self._list_buf = [body]
|
|
self._list_ordered = ordered
|
|
self._list_levels = [level]
|
|
|
|
def _as_plain_paragraph(self, para: _Para, out: list[Block]) -> list[Block]:
|
|
self._flush_code(out)
|
|
self._flush_list(out)
|
|
if not para.text and not paragraph_has_omml(para.element):
|
|
return out
|
|
spans = _inline_spans(para.paragraph)
|
|
# A paragraph holding nothing but OMML is a display equation
|
|
oms = find_omml(para.element)
|
|
if oms and not para.text.replace(" ", ""):
|
|
latex = omml_to_latex(oms[0])
|
|
if latex:
|
|
out.append(EquationBlock(latex=latex))
|
|
return out
|
|
out.append(ParagraphBlock(spans=spans, style=para.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
|