forked from erp-dev/erp
320 lines
10 KiB
Python
320 lines
10 KiB
Python
"""
|
||
DOCX template rendering utilities for printing.
|
||
|
||
This repo's print templates (e.g. `print-template.docx`) use placeholder tokens like:
|
||
- `#{打印-销售单(8*9).客户名称}`
|
||
- `{备注}`
|
||
|
||
Important: Microsoft Word / WPS may split a token across multiple "runs" (w:r / w:t).
|
||
So we must replace placeholders on the concatenated text of a paragraph/cell, then
|
||
write back to runs to avoid leaving raw tokens in the output.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import re
|
||
from collections.abc import Iterable, Mapping
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from docx.document import Document as _DocumentType
|
||
from docx.table import Table, _Cell
|
||
from docx.text.paragraph import Paragraph
|
||
|
||
|
||
_RE_HASH_PLACEHOLDER = re.compile(r"#\{(.{1,300}?)\}", re.S)
|
||
# Plain `{xxx}` but not `#{xxx}` (negative lookbehind on '#')
|
||
_RE_BRACE_PLACEHOLDER = re.compile(r"(?<!#)\{([^{}\r\n]{1,200})\}")
|
||
|
||
|
||
def _normalize_placeholder_inner(s: str) -> str:
|
||
# In real templates, tokens may be split with newlines/spaces across runs.
|
||
# Normalizing by removing all whitespace makes keys stable for mapping.
|
||
return re.sub(r"\s+", "", (s or "")).strip()
|
||
|
||
|
||
def _normalize_context(context: Mapping[str, Any]) -> dict[str, str]:
|
||
"""
|
||
Normalize context keys by stripping whitespace, and stringify values.
|
||
"""
|
||
m: dict[str, str] = {}
|
||
for k, v in (context or {}).items():
|
||
kk = _normalize_placeholder_inner(str(k))
|
||
m[kk] = "" if v is None else str(v)
|
||
return m
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DocxRenderStats:
|
||
paragraphs_touched: int
|
||
placeholders_replaced_or_cleared: int
|
||
|
||
|
||
def _iter_paragraphs_in_table(table: Table) -> Iterable[Paragraph]:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
yield from _iter_paragraphs_in_cell(cell)
|
||
|
||
|
||
def _iter_paragraphs_in_cell(cell: _Cell) -> Iterable[Paragraph]:
|
||
for p in cell.paragraphs:
|
||
yield p
|
||
for t in cell.tables:
|
||
yield from _iter_paragraphs_in_table(t)
|
||
|
||
|
||
def _iter_all_paragraphs_in_doc(d: _DocumentType) -> Iterable[Paragraph]:
|
||
for p in d.paragraphs:
|
||
yield p
|
||
for t in d.tables:
|
||
yield from _iter_paragraphs_in_table(t)
|
||
|
||
# headers / footers may also contain placeholders
|
||
for sec in d.sections:
|
||
for p in sec.header.paragraphs:
|
||
yield p
|
||
for t in sec.header.tables:
|
||
yield from _iter_paragraphs_in_table(t)
|
||
for p in sec.footer.paragraphs:
|
||
yield p
|
||
for t in sec.footer.tables:
|
||
yield from _iter_paragraphs_in_table(t)
|
||
|
||
|
||
def _replace_placeholders_in_paragraphs(
|
||
*,
|
||
paragraphs: Iterable[Paragraph],
|
||
context: Mapping[str, Any],
|
||
clear_unresolved: bool,
|
||
) -> DocxRenderStats:
|
||
ctx = _normalize_context(context)
|
||
paragraphs_touched = 0
|
||
placeholders_done = 0
|
||
|
||
def replace_in_text(text: str) -> tuple[str, int]:
|
||
changed = 0
|
||
|
||
def repl_hash(m: re.Match[str]) -> str:
|
||
nonlocal changed
|
||
raw_inner = m.group(1)
|
||
key = _normalize_placeholder_inner(raw_inner)
|
||
if key in ctx:
|
||
changed += 1
|
||
return ctx[key]
|
||
if clear_unresolved:
|
||
changed += 1
|
||
return ""
|
||
return m.group(0)
|
||
|
||
def repl_brace(m: re.Match[str]) -> str:
|
||
nonlocal changed
|
||
raw_inner = m.group(1)
|
||
key = _normalize_placeholder_inner(raw_inner)
|
||
if key in ctx:
|
||
changed += 1
|
||
return ctx[key]
|
||
if clear_unresolved:
|
||
changed += 1
|
||
return ""
|
||
return m.group(0)
|
||
|
||
new_text = _RE_HASH_PLACEHOLDER.sub(repl_hash, text)
|
||
new_text = _RE_BRACE_PLACEHOLDER.sub(repl_brace, new_text)
|
||
return new_text, changed
|
||
|
||
def write_back_runs_keep_styles(runs, new_text: str) -> None:
|
||
"""
|
||
Best-effort to keep run styles:
|
||
- Keep the same number of runs
|
||
- Distribute new text by original run lengths (last run gets the remainder)
|
||
"""
|
||
orig_lens = [len(r.text or "") for r in runs]
|
||
total_len = sum(orig_lens)
|
||
if total_len <= 0:
|
||
runs[0].text = new_text
|
||
for r in runs[1:]:
|
||
r.text = ""
|
||
return
|
||
|
||
# If new text is shorter, later runs become empty; if longer, last run grows.
|
||
pos = 0
|
||
for idx, r in enumerate(runs):
|
||
if idx == len(runs) - 1:
|
||
r.text = new_text[pos:]
|
||
pos = len(new_text)
|
||
else:
|
||
n = orig_lens[idx]
|
||
r.text = new_text[pos : pos + n]
|
||
pos += n
|
||
# Any remaining runs after pos are already set (some may become "").
|
||
|
||
def replace_in_paragraph(p: Paragraph) -> int:
|
||
nonlocal paragraphs_touched, placeholders_done
|
||
runs = list(p.runs)
|
||
if not runs:
|
||
return 0
|
||
text = "".join(r.text or "" for r in runs)
|
||
# quick filter to skip most paragraphs
|
||
if "#{" not in text and "}" not in text and "{" not in text:
|
||
return 0
|
||
|
||
new_text, changed = replace_in_text(text)
|
||
if changed <= 0 or new_text == text:
|
||
return 0
|
||
|
||
write_back_runs_keep_styles(runs, new_text)
|
||
paragraphs_touched += 1
|
||
placeholders_done += changed
|
||
return changed
|
||
|
||
for p in paragraphs:
|
||
replace_in_paragraph(p)
|
||
|
||
return DocxRenderStats(
|
||
paragraphs_touched=int(paragraphs_touched),
|
||
placeholders_replaced_or_cleared=int(placeholders_done),
|
||
)
|
||
|
||
|
||
def extract_docx_placeholders(*, template_path: str) -> set[str]:
|
||
"""
|
||
Extract placeholder keys from a .docx file (document + header/footer parts if present).
|
||
|
||
Returned keys are normalized (all whitespace removed) so they can be used as mapping keys.
|
||
"""
|
||
import zipfile
|
||
import xml.etree.ElementTree as ET
|
||
|
||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||
keys: set[str] = set()
|
||
|
||
def collect_from_xml(xml_text: str) -> None:
|
||
try:
|
||
root = ET.fromstring(xml_text)
|
||
except Exception:
|
||
return
|
||
texts = [(el.text or "") for el in root.findall(".//w:t", ns)]
|
||
full = "".join(texts)
|
||
for inner in _RE_HASH_PLACEHOLDER.findall(full):
|
||
k = _normalize_placeholder_inner(inner)
|
||
if k:
|
||
keys.add(k)
|
||
for inner in _RE_BRACE_PLACEHOLDER.findall(full):
|
||
k = _normalize_placeholder_inner(inner)
|
||
if k:
|
||
keys.add(k)
|
||
|
||
with zipfile.ZipFile(template_path, "r") as zf:
|
||
for name in zf.namelist():
|
||
if not name.startswith("word/"):
|
||
continue
|
||
if not name.endswith(".xml"):
|
||
continue
|
||
# Focus on common content parts; still safe if more exist.
|
||
if not (
|
||
name == "word/document.xml"
|
||
or name.startswith("word/header")
|
||
or name.startswith("word/footer")
|
||
):
|
||
continue
|
||
try:
|
||
xml = zf.read(name).decode("utf-8", errors="replace")
|
||
except Exception:
|
||
continue
|
||
collect_from_xml(xml)
|
||
|
||
return keys
|
||
|
||
|
||
def render_docx_template(
|
||
*,
|
||
template_path: str,
|
||
output_path: str,
|
||
context: Mapping[str, Any],
|
||
clear_unresolved: bool = True,
|
||
) -> DocxRenderStats:
|
||
"""
|
||
Render a .docx template with placeholder replacement.
|
||
|
||
- Replace `#{...}` and `{...}` tokens using `context` (key = inner token).
|
||
- If a token is not found in context:
|
||
- cleared to empty string when `clear_unresolved=True`
|
||
- kept as-is when `clear_unresolved=False`
|
||
|
||
Notes:
|
||
- This function intentionally handles tokens split across runs by replacing on
|
||
concatenated text and writing back to runs (first run gets full text; others cleared).
|
||
"""
|
||
from docx import Document
|
||
|
||
doc: _DocumentType = Document(template_path)
|
||
stats = _replace_placeholders_in_paragraphs(
|
||
paragraphs=_iter_all_paragraphs_in_doc(doc),
|
||
context=context,
|
||
clear_unresolved=bool(clear_unresolved),
|
||
)
|
||
|
||
doc.save(output_path)
|
||
return stats
|
||
|
||
|
||
def render_docx_template_pages(
|
||
*,
|
||
template_path: str,
|
||
output_path: str,
|
||
pages_contexts: list[Mapping[str, Any]],
|
||
clear_unresolved: bool = True,
|
||
) -> DocxRenderStats:
|
||
"""
|
||
Render multiple pages from a single-page docx template by duplicating the template
|
||
table(s) for each page and filling placeholders per-page.
|
||
|
||
Assumption (true for this repo's `print-template.docx`):
|
||
- The whole page layout is in the document body tables.
|
||
- Each page uses the same placeholder names; we fill them page-by-page.
|
||
"""
|
||
if not pages_contexts:
|
||
raise ValueError("pages_contexts 不能为空")
|
||
|
||
from docx import Document
|
||
|
||
doc: _DocumentType = Document(template_path)
|
||
template_tables = list(doc.tables)
|
||
if not template_tables:
|
||
raise ValueError("模板 docx 不包含任何 table,无法按页复制")
|
||
|
||
pages_tables: list[list[Table]] = []
|
||
# page 1 uses existing tables
|
||
pages_tables.append(list(template_tables))
|
||
|
||
# subsequent pages: page break + deep copy template tables
|
||
for _i in range(2, len(pages_contexts) + 1):
|
||
doc.add_page_break()
|
||
new_tables: list[Table] = []
|
||
for t in template_tables:
|
||
new_tbl_el = copy.deepcopy(t._tbl) # pylint: disable=protected-access
|
||
doc._body._body.append(new_tbl_el) # type: ignore[attr-defined] # pylint: disable=protected-access
|
||
new_tables.append(Table(new_tbl_el, doc._body)) # type: ignore[arg-type]
|
||
pages_tables.append(new_tables)
|
||
|
||
total_stats = DocxRenderStats(paragraphs_touched=0, placeholders_replaced_or_cleared=0)
|
||
|
||
for page_idx, (ctx, tbls) in enumerate(zip(pages_contexts, pages_tables), start=1):
|
||
page_paragraphs: list[Paragraph] = []
|
||
for t in tbls:
|
||
page_paragraphs.extend(list(_iter_paragraphs_in_table(t)))
|
||
st = _replace_placeholders_in_paragraphs(
|
||
paragraphs=page_paragraphs,
|
||
context=ctx,
|
||
clear_unresolved=bool(clear_unresolved),
|
||
)
|
||
total_stats = DocxRenderStats(
|
||
paragraphs_touched=total_stats.paragraphs_touched + st.paragraphs_touched,
|
||
placeholders_replaced_or_cleared=total_stats.placeholders_replaced_or_cleared + st.placeholders_replaced_or_cleared,
|
||
)
|
||
|
||
doc.save(output_path)
|
||
return total_stats
|
||
|