v0.5.3
- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
+214
-190
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user