@@ -0,0 +1,162 @@
|
||||
from copy import copy
|
||||
from typing import Generator
|
||||
import re
|
||||
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from docx.shared import Parented, Length
|
||||
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from ..layout_tracker import LayoutState
|
||||
from .paragraph import Paragraph
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
# Leading section numbers written in markdown: "1 ", "1.1 ", "1.2.3. "
|
||||
_LEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+")
|
||||
|
||||
|
||||
class Heading(Paragraph):
|
||||
def __init__(self, parent: Parented, level: int, numbered: bool,
|
||||
numbering_mode: str = "manual"):
|
||||
super().__init__(parent)
|
||||
|
||||
self._numbered = numbered
|
||||
self._numbering_mode = numbering_mode # auto | manual
|
||||
self._parent = parent
|
||||
self._level = level
|
||||
|
||||
if not 1 <= level <= 9:
|
||||
raise ValueError("Heading level must be in range from 1 to 9")
|
||||
|
||||
self.style = f"Heading {level}"
|
||||
|
||||
if not numbered:
|
||||
self._remove_numbering()
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
elif numbering_mode == "manual":
|
||||
# Digits already in markdown text — kill Word list numbering to avoid "1 1 …"
|
||||
self._remove_numbering()
|
||||
|
||||
self._rendered_page = 0
|
||||
self._title_stripped = False
|
||||
|
||||
@property
|
||||
def is_numbered(self) -> bool:
|
||||
return self._numbered
|
||||
|
||||
@property
|
||||
def numbering_mode(self) -> str:
|
||||
return self._numbering_mode
|
||||
|
||||
@numbering_mode.setter
|
||||
def numbering_mode(self, value: str):
|
||||
self._numbering_mode = value
|
||||
if self._numbered and value == "manual":
|
||||
self._remove_numbering()
|
||||
|
||||
@property
|
||||
def rendered_page(self) -> int:
|
||||
return self._rendered_page
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
return self._level
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._docx_paragraph.text
|
||||
|
||||
def apply_numbering_mode(self, mode: str):
|
||||
"""Apply auto/manual after runs are filled."""
|
||||
self._numbering_mode = mode
|
||||
if not self._numbered:
|
||||
return
|
||||
if mode == "manual":
|
||||
self._remove_numbering()
|
||||
elif mode == "auto":
|
||||
self._strip_leading_number_from_runs()
|
||||
|
||||
def _strip_leading_number_from_runs(self):
|
||||
"""Remove '1 ', '1.1 ' etc. from text so Word list provides the number."""
|
||||
if self._title_stripped or not self._numbered:
|
||||
return
|
||||
full = self._docx_paragraph.text or ""
|
||||
m = _LEADING_NUMBER_RE.match(full)
|
||||
if not m:
|
||||
return
|
||||
# Rewrite runs: put remaining text into first run, clear the rest
|
||||
remaining = full[m.end():]
|
||||
runs = self._docx_paragraph.runs
|
||||
if not runs:
|
||||
return
|
||||
runs[0].text = remaining
|
||||
for r in runs[1:]:
|
||||
r.text = ""
|
||||
self._title_stripped = True
|
||||
|
||||
def _remove_numbering(self):
|
||||
pPr = self._docx_paragraph._p.get_or_add_pPr()
|
||||
# Remove existing numPr if any, then set numId=0
|
||||
for child in list(pPr):
|
||||
if child.tag.endswith("}numPr"):
|
||||
pPr.remove(child)
|
||||
pPr.append(
|
||||
create_element("w:numPr", [
|
||||
create_element("w:ilvl", {
|
||||
"w:val": "0"
|
||||
}),
|
||||
create_element("w:numId", {
|
||||
"w:val": "0"
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
def exclude_from_toc(self):
|
||||
"""Keep Heading N look, but outline level = Body Text so Word TOC skips it."""
|
||||
pPr = self._docx_paragraph._p.get_or_add_pPr()
|
||||
for child in list(pPr):
|
||||
if child.tag.endswith("}outlineLvl"):
|
||||
pPr.remove(child)
|
||||
# 9 = body text (not outline levels 1–3 used by TOC \o "1-3")
|
||||
pPr.append(create_element("w:outlineLvl", {"w:val": "9"}))
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
remaining_height = layout_state.remaining_page_height
|
||||
|
||||
if self._level == 1 and layout_state.page != 1 and\
|
||||
not (isinstance(previous_rendered.docx_element, DocxParagraph)
|
||||
and previous_rendered.docx_element.text == "\n"):
|
||||
self.page_break_before = True
|
||||
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width).calculate_height()
|
||||
|
||||
if layout_state.current_page_height == 0 and layout_state.page != 1:
|
||||
height_data.before = 0
|
||||
|
||||
# if a heading + 3 lines don't fit to the page, they go to the next page
|
||||
if ((height_data.lines + 3 - 1) * height_data.line_spacing + 1) * height_data.line_height\
|
||||
> layout_state.remaining_page_height:
|
||||
self._docx_paragraph.paragraph_format.space_before = 0 # libreoffice fix
|
||||
height = height_data.full - height_data.before
|
||||
|
||||
# force this behaviour as there could be a table or an image instead of text
|
||||
self.page_break_before = True
|
||||
|
||||
else:
|
||||
height = height_data.full
|
||||
|
||||
if self.page_break_before:
|
||||
height += remaining_height
|
||||
|
||||
layout_state.add_height(height)
|
||||
self._rendered_page = layout_state.page
|
||||
|
||||
yield RenderedInfo(self._docx_paragraph, Length(height))
|
||||
Reference in New Issue
Block a user