304 lines
9.3 KiB
Python
304 lines
9.3 KiB
Python
"""A4 page geometry helpers for portrait / landscape sections."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from docx.enum.section import WD_ORIENT
|
|
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
|
from docx.oxml.ns import qn
|
|
from docx.shared import Mm, Pt
|
|
from lxml import etree
|
|
|
|
from .util import create_element
|
|
|
|
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
_W = f"{{{_W_NS}}}"
|
|
|
|
# GOST-like A4 margins (same as styles._apply_common_page_and_body)
|
|
MARGIN_LEFT = Mm(30)
|
|
MARGIN_RIGHT = Mm(10)
|
|
MARGIN_TOP = Mm(20)
|
|
MARGIN_BOTTOM = Mm(20)
|
|
|
|
# mirea / pis_custom — 30/10/20/20; paco_custom — форма ПАЦО 20/20/20/20
|
|
MARGIN_PRESETS = {
|
|
"mirea": (Mm(30), Mm(10), Mm(20), Mm(20)),
|
|
"pis_custom": (Mm(30), Mm(10), Mm(20), Mm(20)),
|
|
}
|
|
|
|
|
|
def apply_margin_preset(preset: str) -> None:
|
|
"""Switch active page margins used by apply_section_geometry / content_size."""
|
|
global MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM
|
|
left, right, top, bottom = MARGIN_PRESETS.get(preset, MARGIN_PRESETS["mirea"])
|
|
MARGIN_LEFT = left
|
|
MARGIN_RIGHT = right
|
|
MARGIN_TOP = top
|
|
MARGIN_BOTTOM = bottom
|
|
|
|
A4_SHORT = Mm(210)
|
|
A4_LONG = Mm(297)
|
|
|
|
|
|
def is_landscape_section(section) -> bool:
|
|
"""True if section is (or should be treated as) landscape A4."""
|
|
try:
|
|
if section.orientation == WD_ORIENT.LANDSCAPE:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return int(section.page_width) > int(section.page_height)
|
|
|
|
|
|
def apply_section_geometry(section, *, landscape: bool) -> None:
|
|
"""
|
|
Set orientation and page size.
|
|
|
|
Set orientation first (python-docx may swap w/h on change), then force A4 dims.
|
|
"""
|
|
target = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT
|
|
try:
|
|
section.orientation = target
|
|
except Exception:
|
|
pass
|
|
|
|
if landscape:
|
|
section.page_width = A4_LONG
|
|
section.page_height = A4_SHORT
|
|
else:
|
|
section.page_width = A4_SHORT
|
|
section.page_height = A4_LONG
|
|
|
|
section.left_margin = MARGIN_LEFT
|
|
section.right_margin = MARGIN_RIGHT
|
|
section.top_margin = MARGIN_TOP
|
|
section.bottom_margin = MARGIN_BOTTOM
|
|
|
|
# Explicit orient on pgSz for Word.
|
|
pg_sz = section._sectPr.find(qn("w:pgSz"))
|
|
if pg_sz is None:
|
|
pg_sz = section._sectPr._add_pgSz()
|
|
if landscape:
|
|
pg_sz.set(qn("w:orient"), "landscape")
|
|
# Re-assert after XML tweak (some builds reshuffle).
|
|
section.page_width = A4_LONG
|
|
section.page_height = A4_SHORT
|
|
else:
|
|
if pg_sz.get(qn("w:orient")) is not None:
|
|
del pg_sz.attrib[qn("w:orient")]
|
|
section.page_width = A4_SHORT
|
|
section.page_height = A4_LONG
|
|
|
|
# Vertical align: center on landscape (figures/tables only); top on portrait
|
|
sect_pr = section._sectPr
|
|
for el in list(sect_pr.findall(qn("w:vAlign"))):
|
|
sect_pr.remove(el)
|
|
if landscape:
|
|
sect_pr.append(create_element("w:vAlign", {"w:val": "center"}))
|
|
|
|
# add_section copies template sectPr including w:pgNumType/@w:start — strip so
|
|
# landscape / return-to-portrait sections continue numbering instead of restarting.
|
|
clear_section_page_start(section)
|
|
|
|
|
|
def content_size(*, landscape: bool) -> tuple:
|
|
"""Return (max_height, max_width) usable content area for LayoutTracker."""
|
|
if landscape:
|
|
page_w, page_h = A4_LONG, A4_SHORT
|
|
else:
|
|
page_w, page_h = A4_SHORT, A4_LONG
|
|
max_height = page_h - MARGIN_TOP - MARGIN_BOTTOM
|
|
max_width = page_w - MARGIN_LEFT - MARGIN_RIGHT
|
|
return max_height, max_width
|
|
|
|
|
|
def apply_centered_page_footer(section) -> None:
|
|
"""Centered PAGE field, Times New Roman 12 (body section style)."""
|
|
footer = section.footer
|
|
footer.is_linked_to_previous = False
|
|
if not footer.paragraphs:
|
|
footer.add_paragraph()
|
|
paragraph = footer.paragraphs[0]
|
|
paragraph.clear()
|
|
paragraph.paragraph_format.first_line_indent = 0
|
|
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
|
run = paragraph.add_run()
|
|
run.font.name = "Times New Roman"
|
|
run.font.size = Pt(12)
|
|
paragraph._p.append(create_element("w:fldSimple", {
|
|
"w:instr": "PAGE \\* MERGEFORMAT",
|
|
}))
|
|
|
|
|
|
def clear_section_footer(section) -> None:
|
|
"""Empty footer (no PAGE) — for title / assignment / TOC sections."""
|
|
footer = section.footer
|
|
footer.is_linked_to_previous = False
|
|
for p in footer.paragraphs:
|
|
p.clear()
|
|
if not footer.paragraphs:
|
|
footer.add_paragraph()
|
|
|
|
|
|
def clear_section_page_start(section) -> None:
|
|
"""Remove w:pgNumType/@w:start so PAGE continues from the previous section."""
|
|
sect_pr = section._sectPr
|
|
for child in list(sect_pr):
|
|
if child.tag != qn("w:pgNumType"):
|
|
continue
|
|
if child.get(qn("w:start")) is not None:
|
|
del child.attrib[qn("w:start")]
|
|
# Empty pgNumType (no start/fmt) is useless — drop the element.
|
|
if len(child.attrib) == 0:
|
|
sect_pr.remove(child)
|
|
|
|
|
|
def ensure_continuous_page_numbers(document) -> None:
|
|
"""
|
|
After docxcompose of title/assignment: no PAGE on early sections,
|
|
continuous numbering (do not restart at 1 on body section).
|
|
"""
|
|
sections = list(document.sections)
|
|
if not sections:
|
|
return
|
|
|
|
# Strip w:pgNumType start everywhere so landscape / body sections stay continuous.
|
|
for section in sections:
|
|
clear_section_page_start(section)
|
|
|
|
|
|
def set_section_page_start(section, start: int) -> None:
|
|
"""Set w:pgNumType/@w:start so PAGE field begins at ``start`` in this section."""
|
|
if start < 1:
|
|
return
|
|
sect_pr = section._sectPr
|
|
pg = sect_pr.find(qn("w:pgNumType"))
|
|
if pg is None:
|
|
sect_pr.append(create_element("w:pgNumType", {"w:start": str(int(start))}))
|
|
else:
|
|
pg.set(qn("w:start"), str(int(start)))
|
|
|
|
|
|
def apply_page_number_start(
|
|
document,
|
|
start: int | None,
|
|
*,
|
|
front_sections: int = 0,
|
|
) -> None:
|
|
"""
|
|
Apply page-number offset for the body.
|
|
|
|
``start`` is None / <1 — leave continuous numbering (strip explicit starts).
|
|
``start`` >= 1 — first section after front matter restarts PAGE at that number;
|
|
later body sections stay continuous from there.
|
|
"""
|
|
ensure_continuous_page_numbers(document)
|
|
if start is None or int(start) < 1:
|
|
return
|
|
sections = list(document.sections)
|
|
idx = max(0, int(front_sections))
|
|
if idx >= len(sections):
|
|
return
|
|
set_section_page_start(sections[idx], int(start))
|
|
|
|
|
|
def _iter_sect_pr(document_root) -> list:
|
|
"""Return sectPr elements in document order (inline + final body sectPr)."""
|
|
body = document_root.find(f"{_W}body")
|
|
if body is None:
|
|
return []
|
|
sects: list = []
|
|
for child in body:
|
|
tag = child.tag
|
|
if tag == f"{_W}sectPr":
|
|
sects.append(child)
|
|
continue
|
|
if tag != f"{_W}p":
|
|
continue
|
|
p_pr = child.find(f"{_W}pPr")
|
|
if p_pr is None:
|
|
continue
|
|
sect = p_pr.find(f"{_W}sectPr")
|
|
if sect is not None:
|
|
sects.append(sect)
|
|
return sects
|
|
|
|
|
|
def _strip_pg_num_start_xml(sect_pr) -> None:
|
|
for child in list(sect_pr):
|
|
if child.tag != f"{_W}pgNumType":
|
|
continue
|
|
if child.get(f"{_W}start") is not None:
|
|
del child.attrib[f"{_W}start"]
|
|
if len(child.attrib) == 0:
|
|
sect_pr.remove(child)
|
|
|
|
|
|
def _set_pg_num_start_xml(sect_pr, start: int) -> None:
|
|
pg = sect_pr.find(f"{_W}pgNumType")
|
|
if pg is None:
|
|
pg = etree.SubElement(sect_pr, f"{_W}pgNumType")
|
|
pg.set(f"{_W}start", str(int(start)))
|
|
|
|
|
|
def patch_docx_page_starts(
|
|
path: str | Path,
|
|
start: int | None = None,
|
|
*,
|
|
front_sections: int = 0,
|
|
) -> None:
|
|
"""
|
|
Strip (and optionally re-apply) w:pgNumType/@w:start inside word/document.xml.
|
|
|
|
Used after Word COM Save, which often rewrites start=\"1\" onto every section.
|
|
Zip/lxml only — does not round-trip through python-docx (preserves
|
|
continuation captions and other Word-written parts).
|
|
"""
|
|
abs_path = os.path.abspath(str(path))
|
|
if not os.path.isfile(abs_path):
|
|
return
|
|
|
|
with zipfile.ZipFile(abs_path, "r") as zin:
|
|
names = zin.namelist()
|
|
if "word/document.xml" not in names:
|
|
return
|
|
doc_bytes = zin.read("word/document.xml")
|
|
other = {n: zin.read(n) for n in names if n != "word/document.xml"}
|
|
|
|
root = etree.fromstring(doc_bytes)
|
|
sects = _iter_sect_pr(root)
|
|
for sect in sects:
|
|
_strip_pg_num_start_xml(sect)
|
|
|
|
if start is not None and int(start) >= 1:
|
|
idx = max(0, int(front_sections))
|
|
if idx < len(sects):
|
|
_set_pg_num_start_xml(sects[idx], int(start))
|
|
|
|
new_doc = etree.tostring(
|
|
root,
|
|
xml_declaration=True,
|
|
encoding="UTF-8",
|
|
standalone=True,
|
|
)
|
|
|
|
parent = os.path.dirname(abs_path) or "."
|
|
fd, tmp_name = tempfile.mkstemp(suffix=".docx", dir=parent)
|
|
os.close(fd)
|
|
try:
|
|
with zipfile.ZipFile(tmp_name, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
|
for name, data in other.items():
|
|
zout.writestr(name, data)
|
|
zout.writestr("word/document.xml", new_doc)
|
|
os.replace(tmp_name, abs_path)
|
|
except Exception:
|
|
try:
|
|
os.remove(tmp_name)
|
|
except OSError:
|
|
pass
|
|
raise
|