1
0
forked from erp-dev/erp
Files
erpnew/printing/docx_template.py

320 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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