pdfwright 0.2.2__tar.gz → 0.4.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 (136) hide show
  1. {pdfwright-0.2.2 → pdfwright-0.4.0}/.gitignore +2 -0
  2. {pdfwright-0.2.2 → pdfwright-0.4.0}/PKG-INFO +2 -1
  3. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/cooperation_contract.json +2 -2
  4. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/daily_report.json +1 -1
  5. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/generate_all.py +14 -2
  6. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/service_contract.json +2 -2
  7. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/staff_form.json +1 -1
  8. {pdfwright-0.2.2 → pdfwright-0.4.0}/pyproject.toml +20 -3
  9. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/__init__.py +1 -1
  10. pdfwright-0.4.0/src/pdfwright/commands.py +197 -0
  11. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/converters/__init__.py +2 -0
  12. pdfwright-0.4.0/src/pdfwright/converters/docx.py +123 -0
  13. pdfwright-0.4.0/src/pdfwright/converters/docx_block.py +500 -0
  14. pdfwright-0.4.0/src/pdfwright/converters/docx_common.py +198 -0
  15. pdfwright-0.4.0/src/pdfwright/converters/docx_helpers.py +533 -0
  16. pdfwright-0.4.0/src/pdfwright/converters/docx_run.py +604 -0
  17. pdfwright-0.4.0/src/pdfwright/converters/docx_state.py +97 -0
  18. pdfwright-0.4.0/src/pdfwright/converters/docx_table.py +329 -0
  19. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/converters/html.py +130 -73
  20. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/fonts/__init__.py +4 -0
  21. pdfwright-0.4.0/src/pdfwright/fonts/oblique.py +415 -0
  22. pdfwright-0.4.0/src/pdfwright/fonts/registry.py +1366 -0
  23. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/fonts/scanner.py +196 -37
  24. pdfwright-0.4.0/src/pdfwright/fonts/semantic_families.py +436 -0
  25. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/fonts/sfnt.py +48 -14
  26. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/models/__init__.py +14 -0
  27. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/models/borders.py +91 -0
  28. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/models/constants.py +37 -23
  29. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/models/document.py +144 -49
  30. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/models/elements.py +232 -23
  31. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/models/style.py +129 -7
  32. pdfwright-0.4.0/src/pdfwright/models/table_columns.py +211 -0
  33. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/postprocess/__init__.py +12 -0
  34. pdfwright-0.4.0/src/pdfwright/postprocess/fingerprint.py +228 -0
  35. pdfwright-0.4.0/src/pdfwright/postprocess/font_scan.py +164 -0
  36. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/postprocess/pdfa_verify.py +31 -6
  37. pdfwright-0.4.0/src/pdfwright/postprocess/seal.py +662 -0
  38. pdfwright-0.4.0/src/pdfwright/postprocess/signed_verify.py +327 -0
  39. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/render/base.py +89 -13
  40. pdfwright-0.4.0/src/pdfwright/render/codes.py +241 -0
  41. pdfwright-0.4.0/src/pdfwright/render/colors.py +126 -0
  42. pdfwright-0.4.0/src/pdfwright/render/html/__init__.py +160 -0
  43. pdfwright-0.4.0/src/pdfwright/render/html_blocks.py +394 -0
  44. pdfwright-0.4.0/src/pdfwright/render/html_parts.py +469 -0
  45. pdfwright-0.4.0/src/pdfwright/render/html_state.py +58 -0
  46. pdfwright-0.4.0/src/pdfwright/render/html_style.py +452 -0
  47. pdfwright-0.4.0/src/pdfwright/render/html_table.py +427 -0
  48. pdfwright-0.4.0/src/pdfwright/render/kinsoku.py +299 -0
  49. pdfwright-0.4.0/src/pdfwright/render/layout.py +191 -0
  50. pdfwright-0.4.0/src/pdfwright/render/merge.py +403 -0
  51. pdfwright-0.4.0/src/pdfwright/render/options.py +85 -0
  52. pdfwright-0.4.0/src/pdfwright/render/pdf.py +2411 -0
  53. pdfwright-0.4.0/src/pdfwright/render/pdf_blocks.py +563 -0
  54. pdfwright-0.4.0/src/pdfwright/render/pdf_flowables.py +1382 -0
  55. pdfwright-0.4.0/src/pdfwright/render/pdf_media.py +492 -0
  56. pdfwright-0.4.0/src/pdfwright/render/pdf_state.py +84 -0
  57. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/render/pdfa.py +176 -0
  58. pdfwright-0.4.0/src/pdfwright/render/table.py +1526 -0
  59. pdfwright-0.4.0/src/pdfwright/render/units.py +32 -0
  60. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/security.py +79 -4
  61. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/template/__init__.py +9 -1
  62. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/template/engine.py +92 -2
  63. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/utils.py +82 -2
  64. pdfwright-0.4.0/src/pdfwright/validate/__init__.py +1054 -0
  65. pdfwright-0.4.0/tests/conftest.py +66 -0
  66. pdfwright-0.4.0/tests/test_cell_borders.py +411 -0
  67. pdfwright-0.4.0/tests/test_colors.py +80 -0
  68. pdfwright-0.4.0/tests/test_divider_geometry.py +89 -0
  69. pdfwright-0.4.0/tests/test_docx_converter.py +1549 -0
  70. pdfwright-0.4.0/tests/test_fingerprint.py +129 -0
  71. pdfwright-0.4.0/tests/test_font_bold.py +109 -0
  72. pdfwright-0.4.0/tests/test_font_policy.py +290 -0
  73. pdfwright-0.4.0/tests/test_fonts.py +954 -0
  74. pdfwright-0.4.0/tests/test_fonts_oblique.py +144 -0
  75. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_html_converter.py +72 -3
  76. pdfwright-0.4.0/tests/test_kinsoku.py +143 -0
  77. pdfwright-0.4.0/tests/test_line_grid.py +136 -0
  78. pdfwright-0.4.0/tests/test_margins.py +418 -0
  79. pdfwright-0.4.0/tests/test_merge.py +599 -0
  80. pdfwright-0.4.0/tests/test_models.py +632 -0
  81. pdfwright-0.4.0/tests/test_options_and_validation.py +143 -0
  82. pdfwright-0.4.0/tests/test_prewrap_indent.py +272 -0
  83. pdfwright-0.4.0/tests/test_render_codes.py +166 -0
  84. pdfwright-0.4.0/tests/test_render_html.py +1192 -0
  85. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_render_pdf.py +1778 -3
  86. pdfwright-0.4.0/tests/test_render_row.py +782 -0
  87. pdfwright-0.4.0/tests/test_render_table.py +1359 -0
  88. pdfwright-0.4.0/tests/test_row_widths.py +190 -0
  89. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_security.py +57 -1
  90. pdfwright-0.4.0/tests/test_sign_local_stamp.py +955 -0
  91. pdfwright-0.4.0/tests/test_signed_verify.py +244 -0
  92. pdfwright-0.4.0/tests/test_span_expression.py +233 -0
  93. pdfwright-0.4.0/tests/test_style_box.py +134 -0
  94. pdfwright-0.4.0/tests/test_table_vectors.py +77 -0
  95. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_template.py +91 -0
  96. pdfwright-0.4.0/tests/test_validate.py +701 -0
  97. pdfwright-0.2.2/src/pdfwright/converters/docx.py +0 -1471
  98. pdfwright-0.2.2/src/pdfwright/fonts/registry.py +0 -736
  99. pdfwright-0.2.2/src/pdfwright/render/codes.py +0 -143
  100. pdfwright-0.2.2/src/pdfwright/render/html/__init__.py +0 -973
  101. pdfwright-0.2.2/src/pdfwright/render/pdf.py +0 -2427
  102. pdfwright-0.2.2/src/pdfwright/render/table.py +0 -862
  103. pdfwright-0.2.2/src/pdfwright/validate/__init__.py +0 -407
  104. pdfwright-0.2.2/tests/test_docx_converter.py +0 -502
  105. pdfwright-0.2.2/tests/test_fonts.py +0 -174
  106. pdfwright-0.2.2/tests/test_models.py +0 -308
  107. pdfwright-0.2.2/tests/test_render_html.py +0 -570
  108. pdfwright-0.2.2/tests/test_render_table.py +0 -245
  109. pdfwright-0.2.2/tests/test_validate.py +0 -108
  110. {pdfwright-0.2.2 → pdfwright-0.4.0}/README.md +0 -0
  111. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/contract.json +0 -0
  112. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/feature_tour.json +0 -0
  113. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/font_mixing.json +0 -0
  114. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/generate_demo.py +0 -0
  115. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/invoice.json +0 -0
  116. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/output.html +0 -0
  117. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/output.pdf +0 -0
  118. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/path_security.json +0 -0
  119. {pdfwright-0.2.2 → pdfwright-0.4.0}/examples/sample_document.json +0 -0
  120. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/batch.py +0 -0
  121. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/converters/xlsx.py +0 -0
  122. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/errors.py +0 -0
  123. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/fonts/fallback.py +0 -0
  124. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/fonts/system_fonts.py +0 -0
  125. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/postprocess/metadata.py +0 -0
  126. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/py.typed +0 -0
  127. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/render/__init__.py +0 -0
  128. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/render/markup.py +0 -0
  129. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/template/context.py +0 -0
  130. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/template/filters.py +0 -0
  131. {pdfwright-0.2.2 → pdfwright-0.4.0}/src/pdfwright/template/format.py +0 -0
  132. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_examples.py +0 -0
  133. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_render_markup.py +0 -0
  134. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_shared_vectors.py +0 -0
  135. {pdfwright-0.2.2 → pdfwright-0.4.0}/tests/test_xlsx_converter.py +0 -0
  136. {pdfwright-0.2.2 → pdfwright-0.4.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.4.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
  {
@@ -13,6 +13,7 @@ import sys
13
13
  from pathlib import Path
14
14
 
15
15
  from pdfwright import DocumentConfig
16
+ from pdfwright.fonts.registry import has_embeddable_cjk_font
16
17
  from pdfwright.render.html import render_html
17
18
  from pdfwright.render.pdf import render_pdf
18
19
  from pdfwright.validate import validate_document
@@ -26,6 +27,12 @@ def main() -> int:
26
27
  if not check_only:
27
28
  OUTPUT_DIR.mkdir(exist_ok=True)
28
29
 
30
+ # 渲染中文需要可嵌入的中文字体;缺字体时只跳过渲染,校验照跑 ——
31
+ # validate_document 不碰字体,整个脚本提前 return 会让这道关卡空转。
32
+ can_render = has_embeddable_cjk_font()
33
+ if not can_render:
34
+ print("⚠️ 本机没有可嵌入的中文字体(ReportLab 只支持 TrueType/glyf):只校验、不渲染")
35
+
29
36
  files = sorted(EXAMPLES_DIR.glob("*.json"))
30
37
  files = [f for f in files if f.parent == EXAMPLES_DIR]
31
38
  if not files:
@@ -45,6 +52,10 @@ def main() -> int:
45
52
  print(f" [{issue.path}] {issue.message}")
46
53
  continue
47
54
 
55
+ if not can_render:
56
+ print(f"{path.stem:<22}{'✓':<8}{'—':<6}{'(无字体,未渲染)':<20}")
57
+ continue
58
+
48
59
  document = DocumentConfig.from_dict(config)
49
60
  data = config.get("data")
50
61
  try:
@@ -67,7 +78,8 @@ def main() -> int:
67
78
  if failures:
68
79
  print(f"❌ {failures} 个示例未通过")
69
80
  return 1
70
- print(f"✅ {len(files)} 个示例全部通过" + ("" if check_only else f";产物在 {OUTPUT_DIR}"))
81
+ suffix = "" if can_render else "(无字体,仅校验)"
82
+ print(f"✅ {len(files)} 个示例全部通过{suffix}" + ("" if check_only else f";产物在 {OUTPUT_DIR}"))
71
83
  return 0
72
84
 
73
85
 
@@ -78,4 +90,4 @@ def _page_count(pdf_bytes: bytes) -> int:
78
90
 
79
91
 
80
92
  if __name__ == "__main__":
81
- raise SystemExit(main())
93
+ raise SystemExit(main())
@@ -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.4.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]
@@ -35,6 +37,10 @@ exclude = ["examples/output"]
35
37
  addopts = "-ra --strict-markers"
36
38
  testpaths = ["tests"]
37
39
  pythonpath = ["src"]
40
+ markers = [
41
+ # 需要本机装**可嵌入中文字体**的用例(无字体的容器里自动跳过,见 tests/conftest.py)
42
+ "needs_cjk: 用例前提是本机存在可嵌入的中文字面(ReportLab 只支持 TrueType/glyf)",
43
+ ]
38
44
 
39
45
  [tool.ruff]
40
46
  line-length = 100
@@ -81,12 +87,23 @@ check_untyped_defs = true
81
87
  [tool.basedpyright]
82
88
  typeCheckingMode = "basic"
83
89
  include = ["src"]
90
+ # 显式指向 **工作区根** 的 venv:basedpyright 默认会优先用同目录下的 ``.venv``,
91
+ # 而子项目里可能存在空壳/过期的 ``.venv``(本地遗留),于是纯 Python 依赖
92
+ # (pypdf / segno / openpyxl…)解析不到 → 检查结果取决于本机残留目录,不可复现。
93
+ venvPath = ".."
94
+ venv = ".venv"
84
95
  # basedpyright 的 ``ignore`` 只接受「文件/目录 glob」,不支持「每模块忽略哪些规则」
85
96
  # (旧写法 [{module, codes}] 会让 basedpyright 报配置错并以退出码 3 结束,等于从未生效)。
86
- # 这三个解析器大量使用第三方库动态属性,规则级豁免无法表达,故整体不纳入 basedpyright;
87
- # 它们仍由 mypy 检查。
97
+ # 下列模块大量使用第三方库动态属性(python-docx / reportlab / fontTools 的属性在
98
+ # 类型存根里查不到),规则级豁免无法表达,故整体不纳入 basedpyright;仍由 mypy 检查。
99
+ # 逐个列「真实报错」的模块,不是「docx 目录全列」—— docx_helpers / docx_state 目前
100
+ # 干净,未列;新增模块请先单独跑一次 basedpyright 再决定要不要加进来。
88
101
  ignore = [
89
102
  "src/pdfwright/converters/docx.py",
103
+ "src/pdfwright/converters/docx_block.py",
104
+ "src/pdfwright/converters/docx_common.py",
105
+ "src/pdfwright/converters/docx_run.py",
106
+ "src/pdfwright/converters/docx_table.py",
90
107
  "src/pdfwright/converters/xlsx.py",
91
108
  "src/pdfwright/fonts/scanner.py",
92
109
  ]
@@ -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.4.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
@@ -5,6 +5,7 @@ from pdfwright.converters.docx import (
5
5
  DocxField,
6
6
  DocxParseResult,
7
7
  docx_to_schema,
8
+ semantic_family,
8
9
  )
9
10
  from pdfwright.converters.html import (
10
11
  HtmlField,
@@ -23,6 +24,7 @@ __all__ = [
23
24
  "XlsxConverter",
24
25
  "docx_to_schema",
25
26
  "html_to_schema",
27
+ "semantic_family",
26
28
  "strip_html_tags",
27
29
  "xlsx_to_schema",
28
30
  ]
@@ -0,0 +1,123 @@
1
+ """DOCX 转换器:python-docx 解析 Word 文档 → pdfwright schema(高保真 + 模板标签 + 自动字段 key)。
2
+
3
+ 保真原则:**只翻译 docx 里真实存在的信息,不发明样式**。
4
+
5
+ - 段落 = **一个元素**,`text` 是**富文本标记**(``<b>`` / ``<i>`` / ``<u>`` /
6
+ ``<font color= size= face=>``),``{{ 变量 }}`` **保持内联**——不再把
7
+ 「``{{project_name}}日报``」拆成 input + 段落两个区域;
8
+ - **逐 run** 取字体/字号/颜色/粗斜体(Word 的混排段落不会丢格式),但**样式已经表达过的
9
+ 格式不重复写进 `text`**:整段同格式 → `text` 是纯文本(字体/字号/粗细在元素 `style`
10
+ 上);混排段落也只有与样式不同的片段才带标记。样式没有的位(下划线/高亮/上下标/字符间距)
11
+ 留在内联标记里 —— 见 ``_baseline_fmt`` / ``_trim_fmt``;
12
+ - 字体名**原样保留**(文档写 ``宋体`` 就存 ``宋体``、写 ``SimSun`` 就存 ``SimSun``、
13
+ 写 ``华文中宋`` 就存 ``华文中宋``)——导入不替用户挑语义族,跨平台解析交给
14
+ fonts.registry(本机有该字面就精确命中真身,没有才按名字里的风格回退);
15
+ - 表格边框读 ``w:tblBorders`` / ``w:tcBorders``,底纹读 ``w:shd`` ——
16
+ **docx 没有底纹就不画底纹**(早前版本硬编码灰色表头,属于发明);
17
+ - 列宽读 ``w:gridCol`` / ``w:tcW``,垂直对齐读 ``w:vAlign``;
18
+ - 图片提取为 image 元素(base64 data URL);分页符 → pageBreak;
19
+ - 字段识别:Word 内容控件(w:sdt)与**循环外**的 ``{{ 变量 }}`` → 可填字段;
20
+ **循环内**的 ``{{ 变量 }}`` → 保留为模板插值(渲染时按数据求值);
21
+ - 模板标签:``{% for x in y %}`` / ``{% endfor %}`` 可出现在段落或表格单元格中,
22
+ 按出现顺序配对,循环体可跨多行(如「人员明细行 + 合计行」共享一个外层循环)。
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import io
28
+ from typing import Any
29
+
30
+ from docx import Document
31
+
32
+ from pdfwright.converters.docx_block import _DocxBlockMixin
33
+
34
+ # ``DocxField`` 只在这里再导出(公开 API:``converters/__init__.py`` 从本模块取),
35
+ # 本模块自己不用它 —— 故显式标注 F401。(noqa 只能跟规则码,说明写在独立注释行。)
36
+ from pdfwright.converters.docx_common import (
37
+ DocxField, # noqa: F401
38
+ DocxParseResult,
39
+ )
40
+
41
+ # 拆模块后本文件只剩「组合 + 入口」。`font_name` / `scan_jinja_tags` /
42
+ # `strip_jinja_tags` 曾在这里带 F401 抑制注释再导出,但兄弟模块一律**直接**从
43
+ # ``docx_common`` 导入,全仓(含测试与 pdfwright-web)无人从 ``converters.docx``
44
+ # 取这三个名字 —— 那是纯死代码,还会让人误以为这里是它们的公开入口,故移除。
45
+ # ``_format_number`` / ``semantic_family`` 相反:确实有人从这里导入,作为**向后兼容的
46
+ # 公开入口**保留(本模块自己不用),并显式标注 F401。
47
+ # (noqa 指令只能跟规则码,中文说明必须写在独立注释行。)
48
+ from pdfwright.converters.docx_helpers import ( # noqa: F401
49
+ _format_number, # 既有测试从这里导入
50
+ semantic_family, # deprecated shim,见 docx_helpers.semantic_family
51
+ )
52
+ from pdfwright.converters.docx_run import _DocxRunMixin
53
+ from pdfwright.converters.docx_table import _DocxTableMixin
54
+ from pdfwright.errors import ConversionError
55
+ from pdfwright.models import (
56
+ Element,
57
+ )
58
+
59
+
60
+ # 方法按职责拆到这些 mixin(共享状态在 ``docx_state.DocxConverterState`` 声明):
61
+ # 段落与 run → ``docx_run``,表格 → ``docx_table``,块结构与文档级信息 → ``docx_block``。
62
+ class DocxConverter(_DocxRunMixin, _DocxTableMixin, _DocxBlockMixin):
63
+ """DOCX → DocumentConfig 转换器。"""
64
+
65
+ def __init__(self, auto_field: bool = True, inline_commands: bool = False) -> None:
66
+ self.auto_field = auto_field
67
+ #: 同一段落里的「文字 + 内联命令标签」是否**排在同一行**(见
68
+ #: ``_append_paragraph_content``)。默认 ``False`` = **块级**:文字与图片/签章位
69
+ #: 各自独立成块(导入结果与旧版逐字节一致,可自由拖拽、重排);
70
+ #: 置 ``True`` 才包一层按内容的 ``row`` 保留 Word 的同行排版。
71
+ self.inline_commands = inline_commands
72
+ self._used_keys: set[str] = set()
73
+ self._part: Any = None
74
+ self._doc_defaults: Any = None
75
+ self._doc_sections: list[Any] = []
76
+ self._section_index = 0
77
+ self._table_margins: Any = None
78
+ # 列表编号:numId → {ilvl: (numFmt, start, lvlText)};计数器随文档顺序累加
79
+ self._numbering: dict[str, dict[int, tuple[str, int, str]]] = {}
80
+ self._number_counters: dict[tuple[str, int], int] = {}
81
+ # 块栈:栈顶是当前打开的容器(``for`` / ``if``),其 children(if 在 else 时为
82
+ # else_children)收后续元素。元素为 ``(kind, node, in_else, chain_start)``;
83
+ # ``chain_start`` 标记 if 链起点(``{% elif %}`` 会在 else 分支里再开一个嵌套 if,
84
+ # 因此 ``{% endif %}`` 要把整条链一起关掉)。
85
+ self._block_stack: list[tuple[str, Element, bool, bool]] = []
86
+ # 内容插入目标覆盖:表格单元格必须写入自己的 td,而不是当前循环体
87
+ self._target_override: list[Element] | None = None
88
+ # 文档水印(浮动图片,``wp:anchor/@behindDoc=1``)
89
+ self._watermark: dict[str, Any] | None = None
90
+ # 文档默认字体(→ page.fontFamily;仅当文档自己声明了才下发,不按语言猜)
91
+ self._document_font_family: str | None = None
92
+
93
+ # ------------------------------------------------------------------ 入口
94
+ def convert(self, source: str | bytes | io.BytesIO) -> DocxParseResult:
95
+ try:
96
+ if isinstance(source, bytes):
97
+ document = Document(io.BytesIO(source)) # pyright: ignore[reportCallIssue]
98
+ elif isinstance(source, io.BytesIO):
99
+ document = Document(source) # pyright: ignore[reportCallIssue]
100
+ else:
101
+ document = Document(source) # pyright: ignore[reportCallIssue]
102
+ except Exception as exc:
103
+ # python-docx 只认 OOXML(.docx/.docm);旧版二进制 .doc 会报
104
+ # PackageNotFoundError → 这里统一转成 ConversionError(HTTP 400 + 可读提示),
105
+ # 否则会以未处理异常冒到 500。
106
+ raise ConversionError(
107
+ message="不是有效的 .docx 文件(旧版二进制 .doc 需先转换为 .docx)",
108
+ detail={"reason": type(exc).__name__},
109
+ cause=exc,
110
+ ) from exc
111
+ self._part = document.part
112
+ return self._convert_doc(document)
113
+
114
+
115
+ def docx_to_schema(
116
+ source: str | bytes | io.BytesIO, auto_field: bool = True, inline_commands: bool = False
117
+ ) -> DocxParseResult:
118
+ """一行调用:DOCX → DocumentConfig + 字段列表。
119
+
120
+ ``inline_commands``:同一段落里的「文字 + 内联命令标签」(``记录人:{{sign:…}}``)
121
+ 是否排在**同一行**(默认 ``False`` = 块级,各自独立成块)。
122
+ """
123
+ return DocxConverter(auto_field=auto_field, inline_commands=inline_commands).convert(source)