140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
from copy import copy
|
||
from typing import Generator
|
||
|
||
from docx.shared import Pt, Cm, Twips
|
||
|
||
from . import Paragraph
|
||
from .renderable import Renderable
|
||
from ..layout_tracker import LayoutState
|
||
from ..rendered_info import RenderedInfo
|
||
|
||
|
||
# ТЗ табл. 2.2: маркер 1,25 см; текст / табуляция 2,25 см
|
||
MARKER_POS = Cm(1.25)
|
||
TEXT_POS = Cm(2.25)
|
||
BULLET = "–" # en dash, единый маркер на весь документ
|
||
|
||
|
||
class List(Renderable):
|
||
def __init__(self, parent, ordered: bool):
|
||
self._parent = parent
|
||
self._ordered = ordered
|
||
self._paragraphs: list[Paragraph] = []
|
||
self._last_paragraph_space_after = 0
|
||
self._numbering = [0 for _ in range(10)]
|
||
self._item_count = 0
|
||
|
||
def add_item(self, level: int) -> Paragraph:
|
||
self._numbering[level - 1] += 1
|
||
for i in range(level, len(self._numbering)):
|
||
self._numbering[i] = 0
|
||
|
||
self._item_count += 1
|
||
paragraph = Paragraph(self._parent)
|
||
|
||
if self._ordered:
|
||
marker = ".".join(str(n) for n in self._numbering[:level]) + "."
|
||
else:
|
||
marker = BULLET
|
||
|
||
paragraph.add_run(marker + "\t")
|
||
|
||
# hanging indent: left = TEXT_POS (+ nesting), first_line = MARKER_POS - TEXT_POS
|
||
nest = TEXT_POS * (level - 1)
|
||
paragraph._docx_paragraph.paragraph_format.tab_stops.add_tab_stop(TEXT_POS + nest)
|
||
paragraph._docx_paragraph.paragraph_format.left_indent = TEXT_POS + nest
|
||
paragraph._docx_paragraph.paragraph_format.first_line_indent = MARKER_POS - TEXT_POS
|
||
|
||
self._last_paragraph_space_after = paragraph._docx_paragraph.paragraph_format.space_after
|
||
paragraph._docx_paragraph.paragraph_format.space_before = 0
|
||
paragraph._docx_paragraph.paragraph_format.space_after = 0
|
||
|
||
self._paragraphs.append(paragraph)
|
||
return paragraph
|
||
|
||
@staticmethod
|
||
def _is_hyphen_run(run) -> bool:
|
||
"""True for a run that carries only w:noBreakHyphen (text is empty)."""
|
||
if run.text:
|
||
return False
|
||
return bool(run._element.xpath("./w:noBreakHyphen"))
|
||
|
||
@classmethod
|
||
def _logical_text(cls, runs) -> str:
|
||
parts = []
|
||
for run in runs:
|
||
if cls._is_hyphen_run(run):
|
||
parts.append("-")
|
||
else:
|
||
parts.append(run.text or "")
|
||
return "".join(parts)
|
||
|
||
def finalize_punctuation(self) -> None:
|
||
"""Apply TZ list punctuation: bullet → lowercase + ';' (last '.'); numbered → Capital + '.'.
|
||
|
||
Case and the final mark are written into existing runs. Runs are not merged:
|
||
merging drops w:noBreakHyphen and inline code formatting, so «REST-интерфейс»
|
||
became «RESTинтерфейс».
|
||
"""
|
||
for i, paragraph in enumerate(self._paragraphs):
|
||
runs = paragraph._docx_paragraph.runs
|
||
if len(runs) < 2:
|
||
continue
|
||
text_runs = runs[1:]
|
||
logical = self._logical_text(text_runs)
|
||
full = logical.strip()
|
||
if not full:
|
||
continue
|
||
is_last = i == len(self._paragraphs) - 1
|
||
if self._ordered:
|
||
fixed = full[0].upper() + full[1:]
|
||
if not fixed.endswith("."):
|
||
fixed = fixed.rstrip(";,. ") + "."
|
||
else:
|
||
fixed = full[0].lower() + full[1:]
|
||
if is_last:
|
||
fixed = fixed.rstrip(";,. ") + "."
|
||
else:
|
||
fixed = fixed.rstrip(";,. ") + ";"
|
||
if logical == fixed:
|
||
continue
|
||
self._apply_list_punctuation(text_runs, full, fixed)
|
||
|
||
@classmethod
|
||
def _apply_list_punctuation(cls, text_runs, full: str, fixed: str) -> None:
|
||
if full[0] != fixed[0]:
|
||
for run in text_runs:
|
||
if cls._is_hyphen_run(run) or not run.text:
|
||
continue
|
||
run.text = fixed[0] + run.text[1:]
|
||
break
|
||
desired_end = fixed[-1]
|
||
last = None
|
||
for run in text_runs:
|
||
if cls._is_hyphen_run(run) or not run.text:
|
||
continue
|
||
last = run
|
||
if last is None:
|
||
return
|
||
trimmed = last.text.rstrip(";,. ")
|
||
if trimmed.endswith(desired_end):
|
||
last.text = trimmed
|
||
else:
|
||
last.text = trimmed + desired_end
|
||
|
||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||
RenderedInfo | Renderable, None, None]:
|
||
self.finalize_punctuation()
|
||
if self._paragraphs:
|
||
self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after
|
||
|
||
if getattr(self, "_space_before_mm6", False) and self._paragraphs:
|
||
from docx.shared import Mm
|
||
self._paragraphs[0]._docx_paragraph.paragraph_format.space_before = Mm(6)
|
||
|
||
for paragraph in self._paragraphs:
|
||
for x in paragraph.render(previous_rendered, copy(layout_state)):
|
||
layout_state.add_height(x.height)
|
||
previous_rendered = x
|
||
yield x
|