pdfwright 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {pdfwright-0.2.2 → pdfwright-0.3.0}/.gitignore +2 -0
  2. {pdfwright-0.2.2 → pdfwright-0.3.0}/PKG-INFO +2 -1
  3. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/cooperation_contract.json +2 -2
  4. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/daily_report.json +1 -1
  5. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/service_contract.json +2 -2
  6. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/staff_form.json +1 -1
  7. {pdfwright-0.2.2 → pdfwright-0.3.0}/pyproject.toml +8 -1
  8. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/__init__.py +1 -1
  9. pdfwright-0.3.0/src/pdfwright/commands.py +197 -0
  10. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/converters/docx.py +693 -172
  11. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/converters/html.py +113 -67
  12. pdfwright-0.3.0/src/pdfwright/fonts/oblique.py +415 -0
  13. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/fonts/registry.py +362 -61
  14. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/fonts/scanner.py +136 -31
  15. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/fonts/sfnt.py +48 -14
  16. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/models/__init__.py +10 -0
  17. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/models/borders.py +91 -0
  18. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/models/document.py +20 -27
  19. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/models/elements.py +146 -9
  20. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/models/style.py +56 -5
  21. pdfwright-0.3.0/src/pdfwright/models/table_columns.py +211 -0
  22. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/postprocess/__init__.py +12 -0
  23. pdfwright-0.3.0/src/pdfwright/postprocess/fingerprint.py +228 -0
  24. pdfwright-0.3.0/src/pdfwright/postprocess/font_scan.py +164 -0
  25. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/postprocess/pdfa_verify.py +31 -6
  26. pdfwright-0.3.0/src/pdfwright/postprocess/seal.py +662 -0
  27. pdfwright-0.3.0/src/pdfwright/postprocess/signed_verify.py +327 -0
  28. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/render/base.py +87 -12
  29. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/render/codes.py +13 -4
  30. pdfwright-0.3.0/src/pdfwright/render/colors.py +126 -0
  31. pdfwright-0.3.0/src/pdfwright/render/html/__init__.py +1598 -0
  32. pdfwright-0.3.0/src/pdfwright/render/layout.py +191 -0
  33. pdfwright-0.3.0/src/pdfwright/render/merge.py +403 -0
  34. pdfwright-0.3.0/src/pdfwright/render/options.py +85 -0
  35. pdfwright-0.3.0/src/pdfwright/render/pdf.py +4358 -0
  36. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/render/pdfa.py +176 -0
  37. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/render/table.py +679 -26
  38. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/security.py +79 -4
  39. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/template/__init__.py +9 -1
  40. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/template/engine.py +92 -2
  41. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/utils.py +37 -0
  42. pdfwright-0.3.0/src/pdfwright/validate/__init__.py +842 -0
  43. pdfwright-0.3.0/tests/test_cell_borders.py +345 -0
  44. pdfwright-0.3.0/tests/test_colors.py +80 -0
  45. pdfwright-0.3.0/tests/test_docx_converter.py +1246 -0
  46. pdfwright-0.3.0/tests/test_fingerprint.py +129 -0
  47. pdfwright-0.3.0/tests/test_font_bold.py +109 -0
  48. pdfwright-0.3.0/tests/test_font_policy.py +290 -0
  49. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_fonts.py +104 -0
  50. pdfwright-0.3.0/tests/test_fonts_oblique.py +144 -0
  51. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_html_converter.py +72 -3
  52. pdfwright-0.3.0/tests/test_margins.py +418 -0
  53. pdfwright-0.3.0/tests/test_merge.py +599 -0
  54. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_models.py +103 -0
  55. pdfwright-0.3.0/tests/test_options_and_validation.py +143 -0
  56. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_render_html.py +449 -0
  57. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_render_pdf.py +1582 -3
  58. pdfwright-0.3.0/tests/test_render_row.py +782 -0
  59. pdfwright-0.3.0/tests/test_render_table.py +1359 -0
  60. pdfwright-0.3.0/tests/test_row_widths.py +190 -0
  61. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_security.py +57 -1
  62. pdfwright-0.3.0/tests/test_sign_local_stamp.py +955 -0
  63. pdfwright-0.3.0/tests/test_signed_verify.py +244 -0
  64. pdfwright-0.3.0/tests/test_span_expression.py +233 -0
  65. pdfwright-0.3.0/tests/test_style_box.py +134 -0
  66. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_template.py +91 -0
  67. pdfwright-0.3.0/tests/test_validate.py +284 -0
  68. pdfwright-0.2.2/src/pdfwright/render/html/__init__.py +0 -973
  69. pdfwright-0.2.2/src/pdfwright/render/pdf.py +0 -2427
  70. pdfwright-0.2.2/src/pdfwright/validate/__init__.py +0 -407
  71. pdfwright-0.2.2/tests/test_docx_converter.py +0 -502
  72. pdfwright-0.2.2/tests/test_render_table.py +0 -245
  73. pdfwright-0.2.2/tests/test_validate.py +0 -108
  74. {pdfwright-0.2.2 → pdfwright-0.3.0}/README.md +0 -0
  75. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/contract.json +0 -0
  76. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/feature_tour.json +0 -0
  77. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/font_mixing.json +0 -0
  78. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/generate_all.py +0 -0
  79. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/generate_demo.py +0 -0
  80. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/invoice.json +0 -0
  81. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/output.html +0 -0
  82. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/output.pdf +0 -0
  83. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/path_security.json +0 -0
  84. {pdfwright-0.2.2 → pdfwright-0.3.0}/examples/sample_document.json +0 -0
  85. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/batch.py +0 -0
  86. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/converters/__init__.py +0 -0
  87. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/converters/xlsx.py +0 -0
  88. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/errors.py +0 -0
  89. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/fonts/__init__.py +0 -0
  90. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/fonts/fallback.py +0 -0
  91. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/fonts/system_fonts.py +0 -0
  92. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/models/constants.py +0 -0
  93. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/postprocess/metadata.py +0 -0
  94. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/py.typed +0 -0
  95. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/render/__init__.py +0 -0
  96. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/render/markup.py +0 -0
  97. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/template/context.py +0 -0
  98. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/template/filters.py +0 -0
  99. {pdfwright-0.2.2 → pdfwright-0.3.0}/src/pdfwright/template/format.py +0 -0
  100. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_examples.py +0 -0
  101. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_render_markup.py +0 -0
  102. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_shared_vectors.py +0 -0
  103. {pdfwright-0.2.2 → pdfwright-0.3.0}/tests/test_xlsx_converter.py +0 -0
  104. {pdfwright-0.2.2 → pdfwright-0.3.0}/uv.lock +0 -0
@@ -31,3 +31,5 @@ pdfwright/examples/output/
31
31
 
32
32
  # 本地发布凭据(scripts/publish.sh --config 默认读它)
33
33
  .publish.env
34
+
35
+ runtime/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pdfwright
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: 配置驱动的 PDF 生成基础库:JSON 配置 → 模板求值(变量/循环/条件)→ 高保真 PDF。纯 Python 引擎(ReportLab + fontTools + Jinja2 + pikepdf),无外部软件依赖。
5
5
  License: Proprietary
6
6
  Requires-Python: >=3.12
@@ -11,6 +11,7 @@ Requires-Dist: jsonschema>=4.23
11
11
  Requires-Dist: openpyxl>=3.1
12
12
  Requires-Dist: pikepdf>=8.0
13
13
  Requires-Dist: pillow>=10.0
14
+ Requires-Dist: pypdf>=4.0
14
15
  Requires-Dist: python-barcode>=0.16.1
15
16
  Requires-Dist: python-docx>=1.1
16
17
  Requires-Dist: reportlab<6,>=5.0
@@ -791,12 +791,12 @@
791
791
  "data": {
792
792
  "party_a": {
793
793
  "name": "xxx公司",
794
- "address": "北京市海淀区中关村大街1号",
794
+ "address": "北京市海淀区某某大街 1 号",
795
795
  "representative": "xxx"
796
796
  },
797
797
  "party_b": {
798
798
  "name": "yyy公司",
799
- "address": "上海市浦东新区张江高科技园区",
799
+ "address": "上海市浦东新区某某高科技园区",
800
800
  "representative": "yyy"
801
801
  }
802
802
  }
@@ -827,7 +827,7 @@
827
827
  "keywords": "工程日报, 施工, 人员, 设备, 材料"
828
828
  },
829
829
  "data": {
830
- "project_name": "城东污水处理厂扩建工程",
830
+ "project_name": "某某污水处理厂扩建工程",
831
831
  "date": "2026-09-19",
832
832
  "person": [
833
833
  {
@@ -532,12 +532,12 @@
532
532
  },
533
533
  "party_a": {
534
534
  "name": "xxx公司",
535
- "address": "北京市海淀区中关村大街1号",
535
+ "address": "北京市海淀区某某大街 1 号",
536
536
  "representative": "xxx"
537
537
  },
538
538
  "party_b": {
539
539
  "name": "yyy公司",
540
- "address": "上海市浦东新区张江高科技园区",
540
+ "address": "上海市浦东新区某某高科技园区",
541
541
  "representative": "yyy"
542
542
  },
543
543
  "clauses": [
@@ -25,7 +25,7 @@
25
25
  { "label": "Vue", "value": "Vue" }, { "label": "Kubernetes", "value": "Kubernetes" }
26
26
  ]}},
27
27
  { "name": "emergency", "type": "string", "label": "紧急联系人", "default": "xxx(父亲)138-1111-2222" },
28
- { "name": "address", "type": "textarea", "label": "现居住地址", "default": "浙江省杭州市余杭区文一西路 969 号" },
28
+ { "name": "address", "type": "textarea", "label": "现居住地址", "default": "浙江省杭州市余杭区某某路 1 号" },
29
29
  { "name": "photo", "type": "image", "label": "证件照" },
30
30
  { "name": "signature", "type": "image", "label": "本人签名", "empty": "blank" }
31
31
  ],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdfwright"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "配置驱动的 PDF 生成基础库:JSON 配置 → 模板求值(变量/循环/条件)→ 高保真 PDF。纯 Python 引擎(ReportLab + fontTools + Jinja2 + pikepdf),无外部软件依赖。"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -17,6 +17,8 @@ dependencies = [
17
17
  "pillow>=10.0",
18
18
  "segno>=1.6.6",
19
19
  "python-barcode>=0.16.1",
20
+ # BSD-3,纯 Python:读回产物做「一致性指纹」(跨平台比对版式与字体,见 postprocess/fingerprint.py)
21
+ "pypdf>=4.0",
20
22
  ]
21
23
 
22
24
  [build-system]
@@ -81,6 +83,11 @@ check_untyped_defs = true
81
83
  [tool.basedpyright]
82
84
  typeCheckingMode = "basic"
83
85
  include = ["src"]
86
+ # 显式指向 **工作区根** 的 venv:basedpyright 默认会优先用同目录下的 ``.venv``,
87
+ # 而子项目里可能存在空壳/过期的 ``.venv``(本地遗留),于是纯 Python 依赖
88
+ # (pypdf / segno / openpyxl…)解析不到 → 检查结果取决于本机残留目录,不可复现。
89
+ venvPath = ".."
90
+ venv = ".venv"
84
91
  # basedpyright 的 ``ignore`` 只接受「文件/目录 glob」,不支持「每模块忽略哪些规则」
85
92
  # (旧写法 [{module, codes}] 会让 basedpyright 报配置错并以退出码 3 结束,等于从未生效)。
86
93
  # 这三个解析器大量使用第三方库动态属性,规则级豁免无法表达,故整体不纳入 basedpyright;
@@ -26,7 +26,7 @@ from pdfwright.postprocess import verify_pdfa
26
26
  from pdfwright.render.html import render_html
27
27
  from pdfwright.render.pdf import PdfRenderer, render_pdf, render_pdf_to_file
28
28
 
29
- __version__ = "0.2.2"
29
+ __version__ = "0.3.0"
30
30
 
31
31
  __all__ = [
32
32
  "BatchItem",
@@ -0,0 +1,197 @@
1
+ """文本命令标签:在 Word 正文里用 ``{{ ... }}`` 描述**非文本元素**。
2
+
3
+ 作者不必用开发工具/内容控件/书签,直接打字即可(导入时转成对应元素):
4
+
5
+ .. list-table::
6
+ :header-rows: 1
7
+
8
+ * - 标签
9
+ - 转成
10
+ * - ``{{__TOC__}}`` / ``{{__TOC__:3}}``
11
+ - ``toc``(可选层级数)
12
+ * - ``{{__pageBreak__}}``
13
+ - ``pageBreak``
14
+ * - ``{{hiddenText:SEAL_PARTY_A}}``
15
+ - ``hiddenText``(不可见、可抽取,供签章系统定位)
16
+ * - ``{{img:photo}}`` / ``{{img:logo.png,mxw_120,mxh_80,dx_3mm,dy_1mm}}``
17
+ - ``image``(``photo`` 视为数据键;带 ``/``/``.``/``:`` 的视为路径或 URL)
18
+ * - ``{{sign:签字人[,数据键][,mxw_…][,mxh_…][,dx_…][,dy_…]}}``
19
+ - ``sign``(第一个位置参数是签章人 = 隐藏文本锚点 + 字段名;第二个当本地签字图的数据键)
20
+ * - ``{{anchor:sec1}}``
21
+ - ``anchor``(命名跳转目标)
22
+
23
+ **与数据表达式的区分**(`{{ }}` 同时是 Jinja 插值语法):
24
+
25
+ - 无参数的单名命令必须写成 ``__名字__``(如 ``__TOC__``),避免与同名数据变量冲突;
26
+ - 带参数的命令用冒号分隔(``img:`` / ``sign:`` / ``hiddenText:`` / ``anchor:``),
27
+ ``{{ img:… }}`` 本身不是合法 Jinja 表达式,所以 ``__`` 可省(写成 ``__img:…__`` 也收);
28
+ - 都匹配不上时**按普通数据表达式处理**,本文档的解析完全不改变既有行为。
29
+
30
+ 渲染端还有一层兜底:万一有命令标签没被导入阶段消费(手写的 JSON、写错的名字),
31
+ 按「空 + 告警」处理,绝不让一个笔误把整篇渲染炸掉(``strip_commands``)。
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from dataclasses import dataclass
38
+
39
+ #: ``{{ ... }}`` 片段(非贪婪,跨行安全)
40
+ TAG_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
41
+ #: 无参数命令(必须写 ``__名字__``)
42
+ NO_ARG_COMMANDS = frozenset({"toc", "pagebreak"})
43
+ #: 带参数命令(``名字:参数,参数``)
44
+ ARG_COMMANDS = frozenset({"hiddentext", "img", "sign", "anchor"})
45
+ ALL_COMMANDS = NO_ARG_COMMANDS | ARG_COMMANDS
46
+
47
+ #: 尺寸参数名 → schema 的 style 键(``mxw_120`` / ``mxw=120`` / ``w_120`` 都收)。
48
+ #: ``mxw/mxh`` 是作者常用的「最大宽/高」写法;当前实现里等价于 image 的
49
+ #: ``style.width``/``style.height``(绘制尺寸,超版心时再等比缩)。
50
+ SIZE_ARGS: dict[str, str] = {
51
+ "mxw": "width",
52
+ "w": "width",
53
+ "mxh": "height",
54
+ "h": "height",
55
+ }
56
+
57
+ #: 相对偏移参数名 → 元素字段(``dx_6mm`` / ``dy_2mm``,也收 ``dx=6mm``)。
58
+ #: **右为正 / 上为正**,与第三方签章服务的 ``leftOrRight`` / ``upOrDown`` 同符号;
59
+ #: 只挪绘制位置、不改变占位尺寸(注意与 ``position:"absolute"`` 的 ``y`` 方向相反)。
60
+ OFFSET_ARGS: dict[str, str] = {
61
+ "dx": "offsetX",
62
+ "dy": "offsetY",
63
+ }
64
+
65
+ _SIZE_RE = re.compile(r"(?i)^(mxw|mxh|w|h)[_=](.+)$")
66
+ _OFFSET_RE = re.compile(r"(?i)^(dx|dy)[_=](.+)$")
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Command:
71
+ """解析出的命令标签(不依赖 schema;由调用方转成元素)。"""
72
+
73
+ name: str
74
+ args: tuple[str, ...]
75
+ body: str
76
+
77
+ def arg(self, index: int) -> str | None:
78
+ """第 ``index`` 个位置参数(无则 None)。"""
79
+ return self.args[index] if index < len(self.args) else None
80
+
81
+ def sizes(self) -> dict[str, str]:
82
+ """``mxw_120,mxh_80`` → ``{"width": "120", "height": "80"}``。"""
83
+ out: dict[str, str] = {}
84
+ for part in self.args:
85
+ match = _SIZE_RE.match(part)
86
+ if match:
87
+ out[SIZE_ARGS[match.group(1).casefold()]] = match.group(2).strip()
88
+ return out
89
+
90
+ def offsets(self) -> dict[str, str]:
91
+ """``dx_6mm,dy_2mm`` → ``{"offsetX": "6mm", "offsetY": "2mm"}``。"""
92
+ out: dict[str, str] = {}
93
+ for part in self.args:
94
+ match = _OFFSET_RE.match(part)
95
+ if match:
96
+ out[OFFSET_ARGS[match.group(1).casefold()]] = match.group(2).strip()
97
+ return out
98
+
99
+ def rest_args(self) -> tuple[str, ...]:
100
+ """既不是尺寸、也不是偏移的位置参数(``img:photo,mxw_120`` → ``("photo",)``)。"""
101
+ return tuple(
102
+ part for part in self.args if not _SIZE_RE.match(part) and not _OFFSET_RE.match(part)
103
+ )
104
+
105
+
106
+ def parse_command(body: str) -> Command | None:
107
+ """``{{ ... }}`` 内部文本 → 命令;不是命令时返回 ``None``(按数据表达式处理)。"""
108
+ text = body.strip()
109
+ if not text:
110
+ return None
111
+ name, _separator, rest = text.partition(":")
112
+ stripped = name.strip()
113
+ key = stripped.strip("_").casefold()
114
+ if key not in ALL_COMMANDS:
115
+ return None
116
+ dunder = stripped.startswith("_") and stripped.endswith("_")
117
+ args = tuple(part.strip() for part in rest.split(",") if part.strip())
118
+ if key in NO_ARG_COMMANDS:
119
+ # 单名命令要求 ``__名字__``:否则 ``{{ toc }}`` 这种数据变量会被误认
120
+ return Command(key, args, text) if dunder else None
121
+ return Command(key, args, text) if args else None
122
+
123
+
124
+ def iter_tags(text: str) -> list[tuple[int, int, str]]:
125
+ """扫出 ``{{ … }}`` 片段 ``(start, end, body)``,**支持嵌套**。
126
+
127
+ 用花括号配对而不是正则:``{{img:{{ item.image }}}}`` 里的内层 ``{{ }}``
128
+ 必须一起算进来,否则非贪婪正则会在内层 ``}}`` 提前截断(实测把模板切成
129
+ ``{{ item.image`` + 一个孤零零的 ``}}``)。
130
+ """
131
+ found: list[tuple[int, int, str]] = []
132
+ index = 0
133
+ length = len(text)
134
+ while True:
135
+ start = text.find("{{", index)
136
+ if start < 0:
137
+ return found
138
+ depth = 1
139
+ cursor = start + 2
140
+ while cursor < length and depth:
141
+ if text.startswith("{{", cursor):
142
+ depth += 1
143
+ cursor += 2
144
+ elif text.startswith("}}", cursor):
145
+ depth -= 1
146
+ cursor += 2
147
+ else:
148
+ cursor += 1
149
+ if depth:
150
+ return found # 未闭合:后面不再是标签
151
+ found.append((start, cursor, text[start + 2 : cursor - 2]))
152
+ index = cursor
153
+
154
+
155
+ def looks_like_command(body: str) -> bool:
156
+ """``{{ … }}`` 内部是否**看起来**是命令标签(首段是已知命令名)。
157
+
158
+ 渲染端兜底用:即使参数写错(如 ``{{img:}}``、``{{__TOC__:x}}``)也当命令处理
159
+ (空 + 告警),而不是丢给 Jinja 报 ``chunk after expression`` 把整篇炸掉。
160
+ """
161
+ name = body.strip().partition(":")[0].strip().strip("_").casefold()
162
+ return name in ALL_COMMANDS
163
+
164
+
165
+ def iter_commands(text: str) -> list[tuple[int, int, Command]]:
166
+ """文本里所有**合法**命令标签的 ``(start, end, command)``(按出现顺序)。"""
167
+ if "{{" not in text:
168
+ return []
169
+ found: list[tuple[int, int, Command]] = []
170
+ for start, end, body in iter_tags(text):
171
+ command = parse_command(body)
172
+ if command is not None:
173
+ found.append((start, end, command))
174
+ return found
175
+
176
+
177
+ def strip_commands(text: str) -> tuple[str, list[str]]:
178
+ """去掉命令标签(渲染端兜底):返回 ``(剩余文本, 遇到的命令标签原文)``。
179
+
180
+ 导入阶段会把命令标签转成元素;万一还有残留(手写 JSON、参数写错的命令),
181
+ 这里把它们当空处理并回报,让渲染端告警而不是抛 Jinja 语法错误。
182
+ """
183
+ if "{{" not in text:
184
+ return text, []
185
+ out: list[str] = []
186
+ names: list[str] = []
187
+ position = 0
188
+ for start, end, body in iter_tags(text):
189
+ if not looks_like_command(body):
190
+ continue
191
+ out.append(text[position:start])
192
+ names.append(body.strip())
193
+ position = end
194
+ if not names:
195
+ return text, []
196
+ out.append(text[position:])
197
+ return "".join(out), names