v0.5.3
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Igor20264
2026-09-10 10:50:15 +03:00
parent 658036f920
commit f7136bd8bd
95 changed files with 7165 additions and 8771 deletions
+214 -190
View File
@@ -3,7 +3,8 @@
from __future__ import annotations
import re
from typing import Callable
from dataclasses import dataclass
from typing import Any, Callable
from docx import Document
from docx.oxml.ns import qn
@@ -27,7 +28,6 @@ from .blocks import (
)
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,
@@ -150,6 +150,16 @@ def _section_landscape(document: Document, element) -> bool:
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,
@@ -222,210 +232,224 @@ class DocxWalker:
def _handle_paragraph(self, paragraph: DocxParagraph) -> list[Block]:
out: list[Block] = []
style = style_name(paragraph)
text = _para_plain(paragraph)
el = paragraph._element
para = _Para(
paragraph=paragraph,
element=paragraph._element,
style=style_name(paragraph),
text=_para_plain(paragraph),
)
# 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))
self._emit_page_breaks(para, out)
if not self._reached_body_start(para):
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
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
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
return self._as_plain_paragraph(para, 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:
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)
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
out.append(PageBreakBlock())
# 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))
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
# 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):
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
spans = _inline_spans(paragraph)
# If whole paragraph is only omml (block-ish), emit equation
oms = find_omml(el)
if oms and not text.replace(" ", ""):
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
m = re.match(r"^\d+\.\s*(.*)$", para.text)
if m:
ordered, body = True, m.group(1)
else:
ordered, body = False, re.sub(r"^[–—\-•▪]\s*", "", para.text)
self._append_list_item(ordered, body, out)
return []
def _as_marker_list_item(self, para: _Para, out: list[Block]) -> list[Block] | None:
list_hit = look_like_list_item(para.text)
indented = para.paragraph.paragraph_format.left_indent or "\t" in (
para.paragraph.text or ""
)
if not (list_hit and indented):
return None
ordered, _ok, body = list_hit
self._flush_code(out)
self._append_list_item(ordered, body, out)
return []
def _append_list_item(self, ordered: bool, body: str, out: list[Block]) -> None:
if self._list_buf is not None and self._list_ordered == ordered:
self._list_buf.append(body)
return
self._flush_list(out)
self._list_buf = [body]
self._list_ordered = ordered
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=style))
out.append(ParagraphBlock(spans=spans, style=para.style))
self._pending_caption = None
return out