1
0
forked from erp-dev/erp

feat: added print-template(test) and sync_mdy_plate_order beat task setting

This commit is contained in:
2026-01-26 21:27:29 +08:00
parent 4d33bc7f68
commit 44557f3d0c
20 changed files with 1275 additions and 0 deletions

319
printing/docx_template.py Normal file
View File

@@ -0,0 +1,319 @@
"""
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