forked from erp-dev/erp
feat: added print-template(test) and sync_mdy_plate_order beat task setting
This commit is contained in:
319
printing/docx_template.py
Normal file
319
printing/docx_template.py
Normal 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
|
||||
|
||||
78
printing/management/commands/render_docx_template.py
Normal file
78
printing/management/commands/render_docx_template.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Render a .docx template by replacing placeholders like #{...} / {...} using a JSON context mapping."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"template",
|
||||
type=str,
|
||||
help="模板文件路径(.docx),例如 /home/f/coding/flower/print-template.docx",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=str,
|
||||
default="rendered.docx",
|
||||
help="输出 docx 路径(默认 rendered.docx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context-json",
|
||||
type=str,
|
||||
default="{}",
|
||||
help="JSON 字符串,键为占位符内部文本(例如 打印-销售单(8*9).客户名称)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context-json-file",
|
||||
type=str,
|
||||
default=None,
|
||||
help="JSON 文件路径(优先级高于 --context-json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-unresolved",
|
||||
action="store_true",
|
||||
help="不清空未命中的占位符(默认会清空)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
template = str(options["template"])
|
||||
out = str(options["out"])
|
||||
context_json = str(options["context_json"] or "{}")
|
||||
context_json_file = options.get("context_json_file")
|
||||
keep_unresolved = bool(options.get("keep_unresolved"))
|
||||
|
||||
tp = Path(template)
|
||||
if not tp.exists():
|
||||
raise CommandError(f"模板文件不存在:{template}")
|
||||
if tp.suffix.lower() != ".docx":
|
||||
raise CommandError("template 必须是 .docx 文件")
|
||||
|
||||
if context_json_file:
|
||||
jp = Path(str(context_json_file))
|
||||
if not jp.exists():
|
||||
raise CommandError(f"JSON 文件不存在:{context_json_file}")
|
||||
context = json.loads(jp.read_text("utf-8"))
|
||||
else:
|
||||
context = json.loads(context_json)
|
||||
|
||||
if not isinstance(context, dict):
|
||||
raise CommandError("context 必须是 JSON object(字典)")
|
||||
|
||||
from printing.docx_template import render_docx_template
|
||||
|
||||
stats = render_docx_template(
|
||||
template_path=str(tp),
|
||||
output_path=str(out),
|
||||
context=context,
|
||||
clear_unresolved=(not keep_unresolved),
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"渲染完成:out={out} paragraphs_touched={stats.paragraphs_touched} placeholders={stats.placeholders_replaced_or_cleared}"
|
||||
)
|
||||
)
|
||||
|
||||
96
printing/management/commands/render_mock_docx.py
Normal file
96
printing/management/commands/render_mock_docx.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Render a .docx template with realistic mock data (no database required)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
type=str,
|
||||
default=str(Path(settings.BASE_DIR) / "print-template.docx"),
|
||||
help="docx 模板路径(默认 BASE_DIR/print-template.docx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=str,
|
||||
default="",
|
||||
help="输出 docx 路径(默认 .tmp_docx/mock-rendered.docx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=20260126,
|
||||
help="随机种子(默认 20260126),便于复现同一份拟真数据",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pages",
|
||||
type=int,
|
||||
default=1,
|
||||
help="生成页数(每页固定 9 行明细),默认 1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--printer",
|
||||
type=str,
|
||||
default="系统自动生成",
|
||||
help="填充 {打印人}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-unresolved",
|
||||
action="store_true",
|
||||
help="不清空未命中的占位符(默认会清空)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
template = str(options.get("template") or "")
|
||||
out = str(options.get("out") or "")
|
||||
seed = int(options.get("seed") or 0)
|
||||
pages = int(options.get("pages") or 1)
|
||||
printer = str(options.get("printer") or "")
|
||||
keep_unresolved = bool(options.get("keep_unresolved"))
|
||||
|
||||
tp = Path(template)
|
||||
if not tp.exists():
|
||||
raise CommandError(f"模板文件不存在:{template}")
|
||||
if tp.suffix.lower() != ".docx":
|
||||
raise CommandError("template 必须是 .docx 文件")
|
||||
|
||||
if not out:
|
||||
out = str(Path(settings.BASE_DIR) / ".tmp_docx" / "mock-rendered.docx")
|
||||
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from printing.docx_template import extract_docx_placeholders, render_docx_template_pages
|
||||
from printing.print_docx_services import build_debug_context_summary, build_mock_sales_docx_pages
|
||||
|
||||
placeholders = extract_docx_placeholders(template_path=str(tp))
|
||||
page_contexts = build_mock_sales_docx_pages(
|
||||
placeholders=placeholders,
|
||||
pages=pages,
|
||||
printer_label=printer,
|
||||
seed=seed,
|
||||
)
|
||||
# stats: aggregate all page contexts
|
||||
ctx_stats = build_debug_context_summary({k: v for d in page_contexts for k, v in d.items()})
|
||||
|
||||
stats = render_docx_template_pages(
|
||||
template_path=str(tp),
|
||||
output_path=str(out),
|
||||
pages_contexts=page_contexts,
|
||||
clear_unresolved=(not keep_unresolved),
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"渲染完成:"
|
||||
f" out={out}"
|
||||
f" seed={seed}"
|
||||
f" pages={pages}"
|
||||
f" paragraphs_touched={stats.paragraphs_touched}"
|
||||
f" placeholders_replaced_or_cleared={stats.placeholders_replaced_or_cleared}"
|
||||
f" context_filled={ctx_stats['context_filled_keys']}/{ctx_stats['context_total_keys']}"
|
||||
)
|
||||
)
|
||||
|
||||
110
printing/management/commands/render_printing_order_docx.py
Normal file
110
printing/management/commands/render_printing_order_docx.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Render a PrintingOrder into a .docx template using real DB data (pick the most 'full' order by default)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--order-id",
|
||||
type=int,
|
||||
default=None,
|
||||
help="指定 PrintingOrder.id;不传则自动选择 PrintingJob 明细最多的一单",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
type=str,
|
||||
default=str(Path(settings.BASE_DIR) / "print-template.docx"),
|
||||
help="docx 模板路径(默认 BASE_DIR/print-template.docx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=str,
|
||||
default="",
|
||||
help="输出 docx 路径(默认 .tmp_docx/printing-order-{id}.docx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--printer",
|
||||
type=str,
|
||||
default="系统自动生成",
|
||||
help="填充 {打印人}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-unresolved",
|
||||
action="store_true",
|
||||
help="不清空未命中的占位符(默认会清空)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
order_id = options.get("order_id")
|
||||
template = str(options.get("template") or "")
|
||||
out = str(options.get("out") or "")
|
||||
printer = str(options.get("printer") or "")
|
||||
keep_unresolved = bool(options.get("keep_unresolved"))
|
||||
|
||||
tp = Path(template)
|
||||
if not tp.exists():
|
||||
raise CommandError(f"模板文件不存在:{template}")
|
||||
if tp.suffix.lower() != ".docx":
|
||||
raise CommandError("template 必须是 .docx 文件")
|
||||
|
||||
from printing.models import PrintingOrder
|
||||
|
||||
qs = PrintingOrder.objects.all()
|
||||
if order_id:
|
||||
order = (
|
||||
qs.select_related("customer", "merchant", "process")
|
||||
.prefetch_related("printing_jobs__product")
|
||||
.filter(id=int(order_id))
|
||||
.first()
|
||||
)
|
||||
if not order:
|
||||
raise CommandError(f"PrintingOrder 不存在:id={order_id}")
|
||||
else:
|
||||
order = (
|
||||
qs.annotate(job_count=Count("printing_jobs"))
|
||||
.filter(job_count__gt=0)
|
||||
.order_by("-job_count", "-id")
|
||||
.select_related("customer", "merchant", "process")
|
||||
.prefetch_related("printing_jobs__product")
|
||||
.first()
|
||||
)
|
||||
if not order:
|
||||
raise CommandError("未找到包含 printing_jobs 的 PrintingOrder(无法做“饱满”渲染测试)")
|
||||
|
||||
# output path default
|
||||
if not out:
|
||||
out = str(Path(settings.BASE_DIR) / ".tmp_docx" / f"printing-order-{order.id}.docx")
|
||||
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from printing.docx_template import extract_docx_placeholders, render_docx_template
|
||||
from printing.print_docx_services import build_debug_context_summary, build_printing_order_docx_context
|
||||
|
||||
placeholders = extract_docx_placeholders(template_path=str(tp))
|
||||
ctx = build_printing_order_docx_context(order=order, placeholders=placeholders, printer_label=printer)
|
||||
ctx_stats = build_debug_context_summary(ctx)
|
||||
|
||||
stats = render_docx_template(
|
||||
template_path=str(tp),
|
||||
output_path=str(out),
|
||||
context=ctx,
|
||||
clear_unresolved=(not keep_unresolved),
|
||||
)
|
||||
|
||||
job_count = order.printing_jobs.count()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"渲染完成:"
|
||||
f" order_id={order.id}"
|
||||
f" job_count={job_count}"
|
||||
f" out={out}"
|
||||
f" paragraphs_touched={stats.paragraphs_touched}"
|
||||
f" placeholders_replaced_or_cleared={stats.placeholders_replaced_or_cleared}"
|
||||
f" context_filled={ctx_stats['context_filled_keys']}/{ctx_stats['context_total_keys']}"
|
||||
)
|
||||
)
|
||||
|
||||
366
printing/print_docx_services.py
Normal file
366
printing/print_docx_services.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
PrintingOrder -> docx template context mapping.
|
||||
|
||||
This is intentionally separated from `printing/services.py` (WeCom logic) to keep concerns clear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from printing.models import PrintingOrder
|
||||
|
||||
|
||||
def _safe_str(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
|
||||
def _fmt_date(dt: datetime | None) -> str:
|
||||
if not dt:
|
||||
return ""
|
||||
try:
|
||||
return timezone.localtime(dt).strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
return str(dt)
|
||||
|
||||
|
||||
def _pick_warehouse_name(order: PrintingOrder) -> str:
|
||||
"""
|
||||
PrintingOrder 模型本身没有 warehouse 字段,这里做一个“尽量饱满”的推断:
|
||||
- 优先取 order.merchant.warehouses.first().name
|
||||
- 否则空字符串
|
||||
"""
|
||||
m = getattr(order, "merchant", None)
|
||||
if not m:
|
||||
return ""
|
||||
try:
|
||||
wh = m.warehouses.order_by("id").first()
|
||||
return _safe_str(getattr(wh, "name", "")).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_max_line_index(placeholders: set[str]) -> int:
|
||||
"""
|
||||
Detect max line index from placeholders like:
|
||||
打印-销售单(8*9).产品名称17
|
||||
打印-销售单(8*9).销售数量32
|
||||
"""
|
||||
max_n = 0
|
||||
for k in placeholders or set():
|
||||
m = re.search(r"(产品名称|幅宽|规格|销售数量|单位)(\d+)$", k)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
n = int(m.group(2))
|
||||
except Exception:
|
||||
continue
|
||||
max_n = max(max_n, n)
|
||||
return max_n
|
||||
|
||||
|
||||
def build_printing_order_docx_context(
|
||||
*,
|
||||
order: PrintingOrder,
|
||||
placeholders: set[str] | None = None,
|
||||
printer_label: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build context mapping for this repo's sales/printing docx template placeholders.
|
||||
|
||||
This function tries to be "data rich" for testing:
|
||||
- Fill header fields: 客户名称/仓库名称/日期/页码
|
||||
- Fill line items from `PrintingOrder.printing_jobs`
|
||||
- Fill per-8-line subtotal: 数量和8/16/24/...
|
||||
- Fill brace placeholders: 备注/打印人
|
||||
"""
|
||||
prefix = "打印-销售单(8*9)."
|
||||
ph = placeholders or set()
|
||||
max_line = _detect_max_line_index(ph) or 32
|
||||
|
||||
customer_name = _safe_str(getattr(getattr(order, "customer", None), "name", ""))
|
||||
warehouse_name = _pick_warehouse_name(order)
|
||||
order_date = _fmt_date(getattr(order, "created_at", None))
|
||||
human_id = _safe_str(getattr(order, "human_id", "")) or _safe_str(getattr(order, "id", ""))
|
||||
|
||||
# remarks: try best-effort
|
||||
remark_parts = []
|
||||
for attr in ("description", "printing_warn", "production_warn", "rolling_warn"):
|
||||
v = _safe_str(getattr(order, attr, "")).strip()
|
||||
if v:
|
||||
remark_parts.append(v)
|
||||
remark = "\n".join(remark_parts[:3]) # avoid overlong
|
||||
|
||||
ctx: dict[str, str] = {
|
||||
# main title
|
||||
f"{prefix}印花销售单": f"印花销售单 {human_id}".strip(),
|
||||
f"{prefix}客户名称": customer_name,
|
||||
f"{prefix}仓库名称": warehouse_name,
|
||||
f"{prefix}日期(订单日期)": order_date,
|
||||
f"{prefix}当前页码": "1",
|
||||
f"{prefix}总页数": "1",
|
||||
# brace placeholders (non-prefixed)
|
||||
"备注": remark,
|
||||
"打印人": (printer_label or "").strip(),
|
||||
}
|
||||
|
||||
# Fill line items from jobs
|
||||
jobs = list(
|
||||
order.printing_jobs.select_related("product").order_by("id")
|
||||
if getattr(order, "id", None)
|
||||
else []
|
||||
)
|
||||
|
||||
# For "尽可能饱满" testing: if few jobs, still fill what we can; unresolved will be cleared by renderer.
|
||||
def _job_product_name(job) -> str:
|
||||
p = getattr(job, "product", None)
|
||||
name = _safe_str(getattr(p, "name", "")).strip()
|
||||
if name:
|
||||
return name
|
||||
return _safe_str(getattr(job, "id", "")).strip()
|
||||
|
||||
def _job_spec(job) -> str:
|
||||
parts = []
|
||||
size = _safe_str(getattr(job, "size", "")).strip()
|
||||
if size:
|
||||
parts.append(size)
|
||||
pieces = getattr(job, "pieces", None)
|
||||
if pieces not in (None, ""):
|
||||
parts.append(f"{pieces}件")
|
||||
return " / ".join(parts)
|
||||
|
||||
def _job_width(job) -> str:
|
||||
# prefer order.width for consistent printing layout
|
||||
w = _safe_str(getattr(order, "width", "")).strip()
|
||||
if w:
|
||||
return w
|
||||
# fallback to product width_size if exists
|
||||
p = getattr(job, "product", None)
|
||||
w2 = getattr(p, "width_size", None)
|
||||
return _safe_str(w2)
|
||||
|
||||
qty_by_index: dict[int, Decimal] = {}
|
||||
|
||||
for i in range(1, max_line + 1):
|
||||
job = jobs[i - 1] if i - 1 < len(jobs) else None
|
||||
if job is None:
|
||||
continue
|
||||
|
||||
qty = getattr(job, "quantity", None)
|
||||
try:
|
||||
qty_dec = Decimal(str(qty)) if qty is not None else Decimal("0")
|
||||
except Exception:
|
||||
qty_dec = Decimal("0")
|
||||
qty_by_index[i] = qty_dec
|
||||
|
||||
ctx[f"{prefix}产品名称{i}"] = _job_product_name(job)
|
||||
ctx[f"{prefix}幅宽{i}"] = _job_width(job)
|
||||
ctx[f"{prefix}规格{i}"] = _job_spec(job)
|
||||
ctx[f"{prefix}销售数量{i}"] = _safe_str(qty)
|
||||
ctx[f"{prefix}单位{i}"] = _safe_str(getattr(job, "unit", "")).strip()
|
||||
|
||||
# Subtotals: 数量和8/16/24/... if template has them (or just fill anyway)
|
||||
for end in range(8, max_line + 1, 8):
|
||||
s = sum((qty_by_index.get(i, Decimal("0")) for i in range(end - 7, end + 1)), Decimal("0"))
|
||||
ctx[f"{prefix}数量和{end}"] = _safe_str(s)
|
||||
|
||||
# If template uses SalesOrder naming but we feed PrintingOrder, at least fill known placeholders.
|
||||
# Any placeholder not covered here will be cleared by renderer (when clear_unresolved=True).
|
||||
return ctx
|
||||
|
||||
|
||||
def build_debug_context_summary(context: Mapping[str, str]) -> dict[str, int]:
|
||||
"""
|
||||
Lightweight stats for logging/command output.
|
||||
"""
|
||||
filled = sum(1 for _k, v in (context or {}).items() if str(v or "").strip())
|
||||
total = len(context or {})
|
||||
return {"context_total_keys": int(total), "context_filled_keys": int(filled)}
|
||||
|
||||
|
||||
def build_mock_sales_docx_context(
|
||||
*,
|
||||
placeholders: set[str] | None = None,
|
||||
printer_label: str = "系统自动生成",
|
||||
seed: int = 20260126,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build a "realistic enough" mock context for quickly testing docx template rendering,
|
||||
without requiring any DB data.
|
||||
|
||||
Notes:
|
||||
- Fills as many indexed line placeholders as detected (default 32).
|
||||
- Also fills per-8-line subtotal placeholders.
|
||||
- Any missing placeholders will be cleared by renderer when clear_unresolved=True.
|
||||
"""
|
||||
import random
|
||||
|
||||
r = random.Random(int(seed))
|
||||
prefix = "打印-销售单(8*9)."
|
||||
ph = placeholders or set()
|
||||
max_line = _detect_max_line_index(ph) or 32
|
||||
|
||||
customer_names = ["宇文服饰", "星河面料", "青山印染", "江南布业", "云图设计"]
|
||||
warehouse_names = ["主仓库", "广州仓", "苏州仓", "上海仓", "临时仓"]
|
||||
crafts = ["活性印花", "分散印花", "数码直喷", "拔染", "涂料印花"]
|
||||
widths = ["145cm", "150cm", "160cm", "180cm"]
|
||||
specs = ["40S*40S", "32S", "21S", "涤棉65/35", "全棉"]
|
||||
units = ["米", "码"]
|
||||
|
||||
now = timezone.localtime(timezone.now())
|
||||
order_date = now.strftime("%Y-%m-%d")
|
||||
human_id = f"{now.strftime('%Y%m%d')}{r.randint(100000, 999999)}"
|
||||
|
||||
ctx: dict[str, str] = {
|
||||
f"{prefix}印花销售单": f"印花销售单 {human_id}",
|
||||
f"{prefix}当前页码": "1",
|
||||
f"{prefix}总页数": "1",
|
||||
f"{prefix}客户名称": r.choice(customer_names),
|
||||
f"{prefix}仓库名称": r.choice(warehouse_names),
|
||||
f"{prefix}日期(订单日期)": order_date,
|
||||
"备注": (
|
||||
f"工艺:{r.choice(crafts)}\n"
|
||||
f"交期:{(now + timezone.timedelta(days=r.randint(3, 10))).strftime('%Y-%m-%d')}\n"
|
||||
f"注意:色差控制、走货前复核数量"
|
||||
),
|
||||
"打印人": (printer_label or "").strip(),
|
||||
}
|
||||
|
||||
# Fill line items 1..N
|
||||
from decimal import Decimal
|
||||
|
||||
qty_by_index: dict[int, Decimal] = {}
|
||||
for i in range(1, max_line + 1):
|
||||
color = r.choice(["蓝", "红", "黑", "绿", "紫", "灰"])
|
||||
craft = r.choice(crafts)
|
||||
name = f"花型{i:02d}-{craft}-{color}"
|
||||
width = r.choice(widths)
|
||||
spec = f"{r.choice(specs)} / {r.randint(45, 120)}gsm"
|
||||
qty = Decimal(str(r.randint(8, 120))) + Decimal(str(r.choice([0, 0, 0, 0.5])))
|
||||
unit = r.choice(units)
|
||||
|
||||
ctx[f"{prefix}产品名称{i}"] = name
|
||||
ctx[f"{prefix}幅宽{i}"] = width
|
||||
ctx[f"{prefix}规格{i}"] = spec
|
||||
ctx[f"{prefix}销售数量{i}"] = _safe_str(qty)
|
||||
ctx[f"{prefix}单位{i}"] = unit
|
||||
qty_by_index[i] = qty
|
||||
|
||||
# Subtotals: 数量和8/16/...
|
||||
for end in range(8, max_line + 1, 8):
|
||||
s = sum((qty_by_index.get(i, Decimal("0")) for i in range(end - 7, end + 1)), Decimal("0"))
|
||||
ctx[f"{prefix}数量和{end}"] = _safe_str(s)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def build_mock_sales_docx_pages(
|
||||
*,
|
||||
placeholders: set[str] | None = None,
|
||||
pages: int = 1,
|
||||
printer_label: str = "系统自动生成",
|
||||
seed: int = 20260126,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Build per-page contexts for the fixed-size template:
|
||||
- 9 rows of items per page
|
||||
- each row has 8 quantity cells => 72 quantity cells per page
|
||||
|
||||
Page-level summary fields (must be per-page):
|
||||
- `匹数`: count of non-empty quantity cells
|
||||
- `总数量`: sum of values in non-empty quantity cells
|
||||
"""
|
||||
import random
|
||||
|
||||
if pages <= 0:
|
||||
return []
|
||||
|
||||
r = random.Random(int(seed))
|
||||
prefix = "打印-销售单(8*9)."
|
||||
|
||||
customer_names = ["宇文服饰", "星河面料", "青山印染", "江南布业", "云图设计"]
|
||||
warehouse_names = ["主仓库", "广州仓", "苏州仓", "上海仓", "临时仓"]
|
||||
crafts = ["活性印花", "分散印花", "数码直喷", "拔染", "涂料印花"]
|
||||
widths = ["145cm", "150cm", "160cm", "180cm"]
|
||||
specs = ["40S*40S", "32S", "21S", "涤棉65/35", "全棉"]
|
||||
units = ["米", "码"]
|
||||
|
||||
now = timezone.localtime(timezone.now())
|
||||
order_date = now.strftime("%Y-%m-%d")
|
||||
human_id = f"{now.strftime('%Y%m%d')}{r.randint(100000, 999999)}"
|
||||
|
||||
# Template rows indices for product fields: 1,9,17,...,65 (9 rows)
|
||||
row_start_indices = [1 + 8 * i for i in range(9)]
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
result: list[dict[str, str]] = []
|
||||
for page_no in range(1, int(pages) + 1):
|
||||
ctx: dict[str, str] = {
|
||||
f"{prefix}印花销售单": f"印花销售单 {human_id}",
|
||||
f"{prefix}当前页码": str(page_no),
|
||||
f"{prefix}总页数": str(pages),
|
||||
f"{prefix}客户名称": r.choice(customer_names),
|
||||
f"{prefix}仓库名称": r.choice(warehouse_names),
|
||||
f"{prefix}日期(订单日期)": order_date,
|
||||
"备注": (
|
||||
f"工艺:{r.choice(crafts)}\n"
|
||||
f"交期:{(now + timezone.timedelta(days=r.randint(3, 10))).strftime('%Y-%m-%d')}\n"
|
||||
f"注意:色差控制、走货前复核数量"
|
||||
),
|
||||
"打印人": (printer_label or "").strip(),
|
||||
}
|
||||
|
||||
bolts = 0
|
||||
total_qty = Decimal("0")
|
||||
|
||||
for row_i, start_idx in enumerate(row_start_indices, start=1):
|
||||
color = r.choice(["蓝", "红", "黑", "绿", "紫", "灰"])
|
||||
craft = r.choice(crafts)
|
||||
name = f"花型{page_no:02d}-{row_i:02d}-{craft}-{color}"
|
||||
width = r.choice(widths)
|
||||
spec = f"{r.choice(specs)} / {r.randint(45, 120)}gsm"
|
||||
unit = r.choice(units)
|
||||
|
||||
ctx[f"{prefix}产品名称{start_idx}"] = name
|
||||
ctx[f"{prefix}幅宽{start_idx}"] = width
|
||||
ctx[f"{prefix}规格{start_idx}"] = spec
|
||||
ctx[f"{prefix}单位{start_idx}"] = unit
|
||||
|
||||
row_sum = Decimal("0")
|
||||
# 8 quantity cells
|
||||
for j in range(8):
|
||||
idx = start_idx + j
|
||||
# make some cells blank to simulate real data
|
||||
if r.random() < 0.18:
|
||||
v = ""
|
||||
else:
|
||||
v = Decimal(str(r.randint(8, 120))) + Decimal(str(r.choice([0, 0, 0, 0.5])))
|
||||
ctx[f"{prefix}销售数量{idx}"] = _safe_str(v)
|
||||
if str(v).strip() != "":
|
||||
bolts += 1
|
||||
try:
|
||||
vv = Decimal(str(v))
|
||||
except Exception:
|
||||
vv = Decimal("0")
|
||||
total_qty += vv
|
||||
row_sum += vv
|
||||
|
||||
# row subtotal placeholder: 数量和8/16/.../72 (end index)
|
||||
row_end = start_idx + 7
|
||||
ctx[f"{prefix}数量和{row_end}"] = _safe_str(row_sum)
|
||||
|
||||
ctx[f"{prefix}匹数"] = _safe_str(bolts)
|
||||
ctx[f"{prefix}总数量"] = _safe_str(total_qty)
|
||||
result.append(ctx)
|
||||
|
||||
return result
|
||||
|
||||
109
printing/test_docx_pagination.py
Normal file
109
printing/test_docx_pagination.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
class DocxPaginationTest(SimpleTestCase):
|
||||
def _read_document_xml(self, docx_path: str) -> str:
|
||||
with zipfile.ZipFile(docx_path, "r") as zf:
|
||||
return zf.read("word/document.xml").decode("utf-8", errors="replace")
|
||||
|
||||
def _extract_pages_text(self, docx_path: str) -> list[str]:
|
||||
"""
|
||||
Extract per-page plain text by walking body elements and splitting on page breaks.
|
||||
|
||||
Important: we extract w:t texts, so assertions won't be broken by XML tag boundaries.
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
xml = self._read_document_xml(docx_path)
|
||||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||
root = ET.fromstring(xml)
|
||||
|
||||
body = root.find("w:body", ns)
|
||||
if body is None:
|
||||
return []
|
||||
|
||||
pages: list[list[str]] = [[]]
|
||||
|
||||
def has_page_break(el) -> bool:
|
||||
for br in el.findall(".//w:br", ns):
|
||||
if br.attrib.get(f"{{{ns['w']}}}type") == "page":
|
||||
return True
|
||||
return False
|
||||
|
||||
def collect_text(el) -> str:
|
||||
ts = [(t.text or "") for t in el.findall(".//w:t", ns)]
|
||||
return "".join(ts)
|
||||
|
||||
for child in list(body):
|
||||
text = collect_text(child)
|
||||
if text:
|
||||
pages[-1].append(text)
|
||||
if has_page_break(child):
|
||||
pages.append([])
|
||||
|
||||
return ["".join(p) for p in pages if "".join(p).strip()]
|
||||
|
||||
def test_render_pages_per_page_totals(self):
|
||||
template = str(Path(settings.BASE_DIR) / "print-template.docx")
|
||||
out = str(Path(settings.BASE_DIR) / ".tmp_docx" / "rendered-pages-test.docx")
|
||||
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from printing.docx_template import render_docx_template_pages
|
||||
|
||||
prefix = "打印-销售单(8*9)."
|
||||
|
||||
# Page 1: two non-empty qty cells => 匹数=2, 总数量=111.11
|
||||
page1 = {
|
||||
f"{prefix}当前页码": "1",
|
||||
f"{prefix}总页数": "2",
|
||||
f"{prefix}匹数": "2",
|
||||
f"{prefix}总数量": "111.11",
|
||||
# Put a couple of quantities too (indices exist 1..72)
|
||||
f"{prefix}销售数量1": "11.11",
|
||||
f"{prefix}销售数量2": "100",
|
||||
# Fill one product line (index 1 exists for product fields)
|
||||
f"{prefix}产品名称1": "P1-产品",
|
||||
f"{prefix}幅宽1": "150cm",
|
||||
f"{prefix}规格1": "规格A",
|
||||
f"{prefix}单位1": "米",
|
||||
}
|
||||
|
||||
# Page 2: one non-empty qty cell => 匹数=1, 总数量=222.22
|
||||
page2 = {
|
||||
f"{prefix}当前页码": "2",
|
||||
f"{prefix}总页数": "2",
|
||||
f"{prefix}匹数": "1",
|
||||
f"{prefix}总数量": "222.22",
|
||||
f"{prefix}销售数量1": "222.22",
|
||||
f"{prefix}产品名称1": "P2-产品",
|
||||
f"{prefix}幅宽1": "160cm",
|
||||
f"{prefix}规格1": "规格B",
|
||||
f"{prefix}单位1": "码",
|
||||
}
|
||||
|
||||
render_docx_template_pages(
|
||||
template_path=template,
|
||||
output_path=out,
|
||||
pages_contexts=[page1, page2],
|
||||
clear_unresolved=True,
|
||||
)
|
||||
|
||||
pages_text = self._extract_pages_text(out)
|
||||
self.assertGreaterEqual(len(pages_text), 2, msg="未生成两页文本(可能分页符缺失)")
|
||||
|
||||
p1, p2 = pages_text[0], pages_text[1]
|
||||
self.assertIn("P1-产品", p1)
|
||||
self.assertIn("111.11", p1)
|
||||
self.assertIn("P2-产品", p2)
|
||||
self.assertIn("222.22", p2)
|
||||
|
||||
# No raw placeholders should remain (check on plain text concatenation)
|
||||
all_text = "".join(pages_text)
|
||||
self.assertIsNone(re.search(r"#\{.{1,300}?\}", all_text, flags=re.S))
|
||||
self.assertIsNone(re.search(r"(?<!#)\{[^{}\r\n]{1,200}\}", all_text))
|
||||
|
||||
55
printing/test_docx_template.py
Normal file
55
printing/test_docx_template.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
class DocxTemplateRenderTest(SimpleTestCase):
|
||||
def _extract_doc_text(self, docx_path: str) -> str:
|
||||
with zipfile.ZipFile(docx_path, "r") as zf:
|
||||
xml = zf.read("word/document.xml").decode("utf-8", errors="replace")
|
||||
# Extract only <w:t> text to avoid false positives in tags/attrs.
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||
root = ET.fromstring(xml)
|
||||
texts = [(el.text or "") for el in root.findall(".//w:t", ns)]
|
||||
return "".join(texts)
|
||||
|
||||
def test_render_replaces_and_clears_placeholders(self):
|
||||
template_path = str(Path(settings.BASE_DIR) / "print-template.docx")
|
||||
self.assertTrue(Path(template_path).exists())
|
||||
|
||||
from printing.docx_template import render_docx_template
|
||||
|
||||
out_path = str(Path(settings.BASE_DIR) / ".tmp_docx" / "rendered-test.docx")
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
render_docx_template(
|
||||
template_path=template_path,
|
||||
output_path=out_path,
|
||||
context={
|
||||
# hash placeholder inner key
|
||||
"打印-销售单(8*9).客户名称": "客户A",
|
||||
# plain brace placeholder inner key
|
||||
"备注": "这是备注",
|
||||
},
|
||||
clear_unresolved=True,
|
||||
)
|
||||
|
||||
text = self._extract_doc_text(out_path)
|
||||
self.assertIn("客户A", text)
|
||||
self.assertIn("这是备注", text)
|
||||
|
||||
# No raw placeholders should remain when clear_unresolved=True
|
||||
self.assertIsNone(
|
||||
re.search(r"#\{.{1,300}?\}", text, flags=re.S),
|
||||
msg="仍残留 #{...} 占位符",
|
||||
)
|
||||
self.assertIsNone(
|
||||
re.search(r"(?<!#)\{[^{}\r\n]{1,200}\}", text),
|
||||
msg="仍残留 {...} 占位符",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user