+125
-15
@@ -2,13 +2,22 @@
|
||||
|
||||
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)
|
||||
@@ -91,6 +100,10 @@ def apply_section_geometry(section, *, landscape: bool) -> None:
|
||||
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."""
|
||||
@@ -131,31 +144,31 @@ def clear_section_footer(section) -> None:
|
||||
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).
|
||||
"""
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
sections = list(document.sections)
|
||||
if not sections:
|
||||
return
|
||||
|
||||
# Heuristic: sections before the first that already has a PAGE field /
|
||||
# or first N-1 if title was prepended — clear footer on all but keep
|
||||
# continuous pgNumType. Body renderer already puts PAGE on body section;
|
||||
# after compose, early sections may inherit footers — clear empty ones
|
||||
# that belong to front matter (no Heading-like body content is hard to
|
||||
# detect), so: clear footer on every section that has no PAGE field, and
|
||||
# strip w:pgNumType start=1 everywhere.
|
||||
# Strip w:pgNumType start everywhere so landscape / body sections stay continuous.
|
||||
for section in sections:
|
||||
sect_pr = section._sectPr
|
||||
for child in list(sect_pr):
|
||||
if child.tag == qn("w:pgNumType"):
|
||||
# Keep continuous: remove start attribute if present
|
||||
if child.get(qn("w:start")) is not None:
|
||||
del child.attrib[qn("w:start")]
|
||||
clear_section_page_start(section)
|
||||
|
||||
|
||||
def set_section_page_start(section, start: int) -> None:
|
||||
@@ -191,3 +204,100 @@ def apply_page_number_start(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user