diff --git a/.tmp_docx/document.xml b/.tmp_docx/document.xml
new file mode 100644
index 0000000..1d92cb8
--- /dev/null
+++ b/.tmp_docx/document.xml
@@ -0,0 +1,2 @@
+
+#{打印-销售单(8*9).印花销售单} 第#{打印-销售单(8*9).当前页码}页 共 #{打印-销售单(8*9).总页数}页 #{打印-销售单(8*9).客户名称}#{打印-销售单(8*9).仓库名称}#{打印-销售单(8*9).日期(订单日期)}#{打印-销售单(8*9).产品名称1}#{打印-销售单(8*9).幅宽1}#{打印-销售单(8*9).规格1}#{打印-销售单(8*9).销售数量1}#{打印-销售单(8*9).销售数量2}#{打印-销售单(8*9).销售数量3}#{打印-销售单(8*9).销售数量4}#{打印-销售单(8*9).销售数量5}#{打印-销售单(8*9).销售数量6}#{打印-销售单(8*9).销售数量7}#{打印-销售单(8*9).销售数量8}#{打印-销售单(8*9).单位1}#{打印-销售单(8*9).数量和8}#{打印-销售单(8*9).产品名称9}#{打印-销售单(8*9).幅宽9}#{打印-销售单(8*9).规格9}#{打印-销售单(8*9).销售数量9}#{打印-销售单(8*9).销售数量10}#{打印-销售单(8*9).销售数量11}#{打印-销售单(8*9).销售数量12}#{打印-销售单(8*9).销售数量13}#{打印-销售单(8*9).销售数量14}#{打印-销售单(8*9).销售数量15}#{打印-销售单(8*9).销售数量16}#{打印-销售单(8*9).单位9}#{打印-销售单(8*9).数量和16}#{打印-销售单(8*9).产品名称17}#{打印-销售单(8*9).幅宽17}#{打印-销售单(8*9).规格17}#{打印-销售单(8*9).销售数量17}#{打印-销售单(8*9).销售数量18}#{打印-销售单(8*9).销售数量19}#{打印-销售单(8*9).销售数量20}#{打印-销售单(8*9).销售数量21}#{打印-销售单(8*9).销售数量22}#{打印-销售单(8*9).销售数量23}#{打印-销售单(8*9).销售数量24}#{打印-销售单(8*9).单位17}#{打印-销售单(8*9).数量和24}#{打印-销售单(8*9).产品名称25}#{打印-销售单(8*9).幅宽25}#{打印-销售单(8*9).规格25}#{打印-销售单(8*9).销售数量25}#{打印-销售单(8*9).销售数量26}#{打印-销售单(8*9).销售数量27}#{打印-销售单(8*9).销售数量28}#{打印-销售单(8*9).销售数量29}#{打印-销售单(8*9).销售数量30}#{打印-销售单(8*9).销售数量31}#{打印-销售单(8*9).销售数量32}#{打印-销售单(8*9).单位25}#{打印-销售单(8*9).数量和32}#{打印-销售单(8*9).产品名称33}#{打印-销售单(8*9).幅宽33}#{打印-销售单(8*9).规格33}#{打印-销售单(8*9).销售数量33}#{打印-销售单(8*9).销售数量34}#{打印-销售单(8*9).销售数量35}#{打印-销售单(8*9).销售数量36}#{打印-销售单(8*9).销售数量37}#{打印-销售单(8*9).销售数量38}#{打印-销售单(8*9).销售数量39}#{打印-销售单(8*9).销售数量40}#{打印-销售单(8*9).单位33}#{打印-销售单(8*9).数量和40}#{打印-销售单(8*9).产品名称41}#{打印-销售单(8*9).幅宽41}#{打印-销售单(8*9).规格41}#{打印-销售单(8*9).销售数量41}#{打印-销售单(8*9).销售数量42}#{打印-销售单(8*9).销售数量43}#{打印-销售单(8*9).销售数量44}#{打印-销售单(8*9).销售数量45}#{打印-销售单(8*9).销售数量46}#{打印-销售单(8*9).销售数量47}#{打印-销售单(8*9).销售数量48}#{打印-销售单(8*9).单位41}#{打印-销售单(8*9).数量和48}#{打印-销售单(8*9).产品名称49}#{打印-销售单(8*9).幅宽49}#{打印-销售单(8*9).规格49}#{打印-销售单(8*9).销售数量49}#{打印-销售单(8*9).销售数量50}#{打印-销售单(8*9).销售数量51}#{打印-销售单(8*9).销售数量52}#{打印-销售单(8*9).销售数量53}#{打印-销售单(8*9).销售数量54}#{打印-销售单(8*9).销售数量55}#{打印-销售单(8*9).销售数量56}#{打印-销售单(8*9).单位49}#{打印-销售单(8*9).数量和56}#{打印-销售单(8*9).产品名称57}#{打印-销售单(8*9).幅宽57}#{打印-销售单(8*9).规格57}#{打印-销售单(8*9).销售数量57}#{打印-销售单(8*9).销售数量58}#{打印-销售单(8*9).销售数量59}#{打印-销售单(8*9).销售数量60}#{打印-销售单(8*9).销售数量61}#{打印-销售单(8*9).销售数量62}#{打印-销售单(8*9).销售数量63}#{打印-销售单(8*9).销售数量64}#{打印-销售单(8*9).单位57}#{打印-销售单(8*9).数量和64}#{打印-销售单(8*9).产品名称65}#{打印-销售单(8*9).幅宽65}#{打印-销售单(8*9).规格65}#{打印-销售单(8*9).销售数量65}#{打印-销售单(8*9).销售数量66}#{打印-销售单(8*9).销售数量67}#{打印-销售单(8*9).销售数量68}#{打印-销售单(8*9).销售数量69}#{打印-销售单(8*9).销售数量70}#{打印-销售单(8*9).销售数量71}#{打印-销售单(8*9).销售数量72}#{打印-销售单(8*9).单位65}#{打印-销售单(8*9).数量和72} #{打印-销售单(8*9).匹数}#{打印-销售单(8*9).总数量} #{备注} #{打印人}#Relation.Repeat[start]##Relation.Repeat[end]#
\ No newline at end of file
diff --git a/.tmp_docx/mock-rendered-2pages.docx b/.tmp_docx/mock-rendered-2pages.docx
new file mode 100644
index 0000000..5169c5f
Binary files /dev/null and b/.tmp_docx/mock-rendered-2pages.docx differ
diff --git a/.tmp_docx/mock-rendered-3pages.docx b/.tmp_docx/mock-rendered-3pages.docx
new file mode 100644
index 0000000..3faef06
Binary files /dev/null and b/.tmp_docx/mock-rendered-3pages.docx differ
diff --git a/.tmp_docx/mock-rendered.docx b/.tmp_docx/mock-rendered.docx
new file mode 100644
index 0000000..b88dad9
Binary files /dev/null and b/.tmp_docx/mock-rendered.docx differ
diff --git a/.tmp_docx/rendered-demo.docx b/.tmp_docx/rendered-demo.docx
new file mode 100644
index 0000000..8734bca
Binary files /dev/null and b/.tmp_docx/rendered-demo.docx differ
diff --git a/.tmp_docx/rendered-pages-test.docx b/.tmp_docx/rendered-pages-test.docx
new file mode 100644
index 0000000..9ac534c
Binary files /dev/null and b/.tmp_docx/rendered-pages-test.docx differ
diff --git a/.tmp_docx/rendered-test.docx b/.tmp_docx/rendered-test.docx
new file mode 100644
index 0000000..2ac3431
Binary files /dev/null and b/.tmp_docx/rendered-test.docx differ
diff --git a/docs/2026-01-26_summary.md b/docs/2026-01-26_summary.md
new file mode 100644
index 0000000..b5e50cd
--- /dev/null
+++ b/docs/2026-01-26_summary.md
@@ -0,0 +1,58 @@
+### 2026-01-26 工作记录
+
+#### 1) 实验性:DOCX 打印模板(.docx)占位符渲染与“剩余变量清空”
+
+背景:
+- 用户提供 `.docx` 打印模板(示例:仓库根目录 `print-template.docx`)
+- 模板变量格式为 `#{...}` / `{...}`,且在 Word/WPS 中可能被拆分到多个 run(`w:t`)里
+- 需求:将业务数据套版后,**未命中的模板变量必须用空字符串清空**,避免打印出原始变量文本
+
+落地内容(实验性,可能废弃,需保留记录):
+- 新增渲染工具:
+ - `printing/docx_template.py`
+ - `render_docx_template(...)`:单页 docx 的占位符替换/清空(支持跨 run 断裂)
+ - `extract_docx_placeholders(...)`:从 docx 抽取占位符 key(document/header/footer)
+ - `render_docx_template_pages(...)`:按页复制模板并逐页渲染(用于固定容量表格分页)
+- 新增 mock 数据生成(用于“尽可能饱满”的套版验证,无需数据库):
+ - `printing/print_docx_services.py`
+ - `build_mock_sales_docx_pages(...)`:按模板“每页 9 行、每行 8 个销售数量格”生成多页 context
+ - 每页汇总字段(按页计算):
+ - `#{打印-销售单(8*9).匹数}`:本页非空“销售数量”格子数
+ - `#{打印-销售单(8*9).总数量}`:本页非空“销售数量”之和
+ - 同时填充 `#{...当前页码}` / `#{...总页数}`
+- 新增命令(便于本地/环境快速验证):
+ - `printing/management/commands/render_docx_template.py`
+ - 用法:`uv run python manage.py render_docx_template --out --context-json '{...}'`
+ - `printing/management/commands/render_printing_order_docx.py`
+ - 从 DB 选取 PrintingOrder 渲染(当前环境无真实数据时会提示无法执行)
+ - `printing/management/commands/render_mock_docx.py`
+ - 用法(多页 mock):`uv run python manage.py render_mock_docx --pages 3 --out .tmp_docx/mock-rendered-3pages.docx`
+- 新增单测(锁住行为:不残留占位符、支持分页):
+ - `printing/test_docx_template.py`
+ - `printing/test_docx_pagination.py`
+ - `printing/test_docx_pagination.py`(按页抽取 `w:t` 文本做断言,避免被 XML 标签分割干扰)
+
+产物(示例输出):
+- `.tmp_docx/mock-rendered-3pages.docx`(多页 mock 套版产物,便于打开核对页码与汇总)
+
+依赖:
+- `python-docx`(通过 `uv add python-docx` 引入;同时安装 `lxml`)
+
+#### 2) 核对:明道云“开版暂存”同步(command / task / beat)
+
+现状确认:
+- command:`api_v1/management/commands/sync_mdy_plate_orders.py`
+ - 底层调用:`api_v1.mdy_plate_order_sync.sync_mdy_plate_orders_to_staging(...)`
+ - 支持参数:`--request-interval-seconds`(用于限流,默认 0.02,约 50 qps 建议值)
+ - 支持游标:`--use-checkpoint/--skip-checkpoint`(DataSync)
+- Celery task:`api_v1/tasks.py::sync_mdy_plate_orders`
+ - 已存在,但当前 `flower/settings.py::CELERY_BEAT_SCHEDULE` **未配置该 task 的定时触发**
+
+待决策(需用户确认后再实施):
+- 是否新增 Celery Beat:每天 02:00-08:00 持续运作(轮询),并通过 `request_interval_seconds` + 每次处理上限(page/max_records/related)控制不超过明道云 QPS 限制。
+
+更新(已实施):
+- 已新增 Celery Beat:`flower/settings.py::CELERY_BEAT_SCHEDULE['mdy_plate_order_staging_sync']`
+ - 时间窗:02:00-07:59(`crontab(minute='*/2', hour='2-7')`)
+ - 任务:`api_v1.tasks.sync_mdy_plate_orders`
+ - 限流参数:`.env` `MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS`(默认 0.05)
diff --git a/env.example b/env.example
index 4a4ea08..1eb0501 100644
--- a/env.example
+++ b/env.example
@@ -50,6 +50,9 @@ MDY_PRODUCT_CATEGORY_ID=1
MDY_SYNC_PAGE_SIZE=100
MDY_SYNC_MAX_PAGES=2
MDY_SYNC_MAX_RECORDS=200
+# 明道云开版暂存同步:每次请求之间的最小间隔(用于限流)
+# - 0.02 ~ 50 qps(理论值),建议生产环境更保守(例如 0.05/0.1)
+MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS=0.05
############################
# 腾讯云 TIIA(建议必填:生产环境请务必使用真实值)
diff --git a/flower/settings.py b/flower/settings.py
index ecbdda9..290265a 100644
--- a/flower/settings.py
+++ b/flower/settings.py
@@ -63,6 +63,9 @@ MDY_PRODUCT_CATEGORY_ID = env.int('MDY_PRODUCT_CATEGORY_ID', default=1)
MDY_SYNC_PAGE_SIZE = env.int('MDY_SYNC_PAGE_SIZE', default=100)
MDY_SYNC_MAX_PAGES = env.int('MDY_SYNC_MAX_PAGES', default=2)
MDY_SYNC_MAX_RECORDS = env.int('MDY_SYNC_MAX_RECORDS', default=200)
+# 明道云“开版暂存”同步:每次请求之间的最小间隔(用于限流)
+# - 0.02 ~ 50 qps(理论值,实际应更保守以预留重试/关联查询)
+MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS = env.float('MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS', default=0.05)
# Tencent Cloud (TIIA) 配置:用于通过 ImageUrl 上传图片到图库(CreateImage)
# 注意:不要把 secret 写死在代码里,建议通过 .env / 环境变量注入。
@@ -530,4 +533,20 @@ CELERY_BEAT_SCHEDULE = {
'max_records': MDY_SYNC_MAX_RECORDS,
},
},
+ # 明道云开版:同步到“暂存表”
+ # - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次)
+ # - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS
+ 'mdy_plate_order_staging_sync': {
+ 'task': 'api_v1.tasks.sync_mdy_plate_orders',
+ # 02:00 - 07:59(不包含 08:xx)
+ 'schedule': crontab(minute='*/2', hour='2-7'),
+ 'kwargs': {
+ 'page_size': max(1, int(MDY_SYNC_PAGE_SIZE)),
+ 'max_pages': max(1, int(MDY_SYNC_MAX_PAGES)),
+ 'max_records': max(1, int(MDY_SYNC_MAX_RECORDS)),
+ 'with_related': True,
+ 'max_related_per_type': 5,
+ 'request_interval_seconds': float(MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS),
+ },
+ },
}
diff --git a/print-template.docx b/print-template.docx
new file mode 100644
index 0000000..99fd5b5
Binary files /dev/null and b/print-template.docx differ
diff --git a/printing/docx_template.py b/printing/docx_template.py
new file mode 100644
index 0000000..1341ab9
--- /dev/null
+++ b/printing/docx_template.py
@@ -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"(? 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
+
diff --git a/printing/management/commands/render_docx_template.py b/printing/management/commands/render_docx_template.py
new file mode 100644
index 0000000..244894b
--- /dev/null
+++ b/printing/management/commands/render_docx_template.py
@@ -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}"
+ )
+ )
+
diff --git a/printing/management/commands/render_mock_docx.py b/printing/management/commands/render_mock_docx.py
new file mode 100644
index 0000000..8196878
--- /dev/null
+++ b/printing/management/commands/render_mock_docx.py
@@ -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']}"
+ )
+ )
+
diff --git a/printing/management/commands/render_printing_order_docx.py b/printing/management/commands/render_printing_order_docx.py
new file mode 100644
index 0000000..63d39ec
--- /dev/null
+++ b/printing/management/commands/render_printing_order_docx.py
@@ -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']}"
+ )
+ )
+
diff --git a/printing/print_docx_services.py b/printing/print_docx_services.py
new file mode 100644
index 0000000..ad70d3e
--- /dev/null
+++ b/printing/print_docx_services.py
@@ -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
+
diff --git a/printing/test_docx_pagination.py b/printing/test_docx_pagination.py
new file mode 100644
index 0000000..46b0ae4
--- /dev/null
+++ b/printing/test_docx_pagination.py
@@ -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"(? str:
+ with zipfile.ZipFile(docx_path, "r") as zf:
+ xml = zf.read("word/document.xml").decode("utf-8", errors="replace")
+ # Extract only 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"(?=5.0.0",
"aiohttp>=3.13.2",
"tencentcloud-sdk-python>=3.0.0",
+ "python-docx>=1.2.0",
]
[dependency-groups]
diff --git a/uv.lock b/uv.lock
index 6049ce5..b31d7fe 100644
--- a/uv.lock
+++ b/uv.lock
@@ -426,6 +426,7 @@ dependencies = [
{ name = "markdown" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
+ { name = "python-docx" },
{ name = "redis" },
{ name = "tencentcloud-sdk-python" },
{ name = "uvicorn" },
@@ -455,6 +456,7 @@ requires-dist = [
{ name = "markdown", specifier = ">=3.10" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
+ { name = "python-docx", specifier = ">=1.2.0" },
{ name = "redis", specifier = ">=5.0.0" },
{ name = "tencentcloud-sdk-python", specifier = ">=3.0.0" },
{ name = "uvicorn", specifier = ">=0.38.0" },
@@ -583,6 +585,50 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" },
]
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
+]
+
[[package]]
name = "markdown"
version = "3.10"
@@ -863,6 +909,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-docx"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.3"