pdfwright 0.2.1__tar.gz → 0.2.2__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 (72) hide show
  1. {pdfwright-0.2.1 → pdfwright-0.2.2}/PKG-INFO +1 -1
  2. {pdfwright-0.2.1 → pdfwright-0.2.2}/pyproject.toml +1 -1
  3. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/__init__.py +1 -1
  4. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/docx.py +8 -2
  5. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/html.py +6 -3
  6. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/document.py +8 -16
  7. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/html/__init__.py +14 -7
  8. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/markup.py +51 -1
  9. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/pdf.py +7 -3
  10. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/context.py +5 -4
  11. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_html_converter.py +3 -3
  12. pdfwright-0.2.2/tests/test_render_markup.py +131 -0
  13. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_template.py +17 -3
  14. {pdfwright-0.2.1 → pdfwright-0.2.2}/.gitignore +0 -0
  15. {pdfwright-0.2.1 → pdfwright-0.2.2}/README.md +0 -0
  16. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/contract.json +0 -0
  17. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/cooperation_contract.json +0 -0
  18. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/daily_report.json +0 -0
  19. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/feature_tour.json +0 -0
  20. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/font_mixing.json +0 -0
  21. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/generate_all.py +0 -0
  22. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/generate_demo.py +0 -0
  23. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/invoice.json +0 -0
  24. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/output.html +0 -0
  25. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/output.pdf +0 -0
  26. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/path_security.json +0 -0
  27. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/sample_document.json +0 -0
  28. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/service_contract.json +0 -0
  29. {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/staff_form.json +0 -0
  30. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/batch.py +0 -0
  31. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/__init__.py +0 -0
  32. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/xlsx.py +0 -0
  33. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/errors.py +0 -0
  34. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/__init__.py +0 -0
  35. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/fallback.py +0 -0
  36. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/registry.py +0 -0
  37. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/scanner.py +0 -0
  38. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/sfnt.py +0 -0
  39. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/system_fonts.py +0 -0
  40. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/__init__.py +0 -0
  41. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/borders.py +0 -0
  42. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/constants.py +0 -0
  43. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/elements.py +0 -0
  44. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/style.py +0 -0
  45. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/postprocess/__init__.py +0 -0
  46. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/postprocess/metadata.py +0 -0
  47. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/postprocess/pdfa_verify.py +0 -0
  48. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/py.typed +0 -0
  49. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/__init__.py +0 -0
  50. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/base.py +0 -0
  51. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/codes.py +0 -0
  52. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/pdfa.py +0 -0
  53. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/table.py +0 -0
  54. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/security.py +0 -0
  55. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/__init__.py +0 -0
  56. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/engine.py +0 -0
  57. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/filters.py +0 -0
  58. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/format.py +0 -0
  59. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/utils.py +0 -0
  60. {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/validate/__init__.py +0 -0
  61. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_docx_converter.py +0 -0
  62. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_examples.py +0 -0
  63. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_fonts.py +0 -0
  64. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_models.py +0 -0
  65. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_render_html.py +0 -0
  66. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_render_pdf.py +0 -0
  67. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_render_table.py +0 -0
  68. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_security.py +0 -0
  69. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_shared_vectors.py +0 -0
  70. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_validate.py +0 -0
  71. {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_xlsx_converter.py +0 -0
  72. {pdfwright-0.2.1 → pdfwright-0.2.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pdfwright
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: 配置驱动的 PDF 生成基础库:JSON 配置 → 模板求值(变量/循环/条件)→ 高保真 PDF。纯 Python 引擎(ReportLab + fontTools + Jinja2 + pikepdf),无外部软件依赖。
5
5
  License: Proprietary
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdfwright"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "配置驱动的 PDF 生成基础库:JSON 配置 → 模板求值(变量/循环/条件)→ 高保真 PDF。纯 Python 引擎(ReportLab + fontTools + Jinja2 + pikepdf),无外部软件依赖。"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.1"
29
+ __version__ = "0.2.2"
30
30
 
31
31
  __all__ = [
32
32
  "BatchItem",
@@ -238,10 +238,15 @@ class DocxField:
238
238
 
239
239
  @dataclass
240
240
  class DocxParseResult:
241
- """DOCX 解析结果。"""
241
+ """DOCX 解析结果。
242
+
243
+ ``data`` 是自动生成的**示例数据**(让导入后立刻能看到渲染效果)—— 它与配置分离,
244
+ 不由 ``DocumentConfig`` 携带,调用方自行按需传给 ``render_pdf``。
245
+ """
242
246
 
243
247
  document: DocumentConfig
244
248
  fields: list[DocxField] = field(default_factory=list)
249
+ data: dict[str, Any] | None = None
245
250
 
246
251
 
247
252
  class DocxConverter:
@@ -347,8 +352,9 @@ class DocxConverter:
347
352
  ]
348
353
  mock = self._build_mock_data(body, fields)
349
354
  return DocxParseResult(
350
- document=DocumentConfig(page=page, body=body, variables=variables, data=mock or None),
355
+ document=DocumentConfig(page=page, body=body, variables=variables),
351
356
  fields=fields,
357
+ data=mock or None,
352
358
  )
353
359
 
354
360
  def _page_config(self, section: Any, previous: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -153,10 +153,14 @@ class HtmlField:
153
153
 
154
154
  @dataclass
155
155
  class HtmlParseResult:
156
- """HTML 解析结果。"""
156
+ """HTML 解析结果。
157
+
158
+ ``data`` 是自动生成的**示例数据**(与配置分离,不由 ``DocumentConfig`` 携带)。
159
+ """
157
160
 
158
161
  document: DocumentConfig
159
162
  fields: list[HtmlField] = field(default_factory=list)
163
+ data: dict[str, Any] | None = None
160
164
 
161
165
 
162
166
  @dataclass
@@ -393,9 +397,8 @@ class _Converter:
393
397
  page=PageConfig(),
394
398
  body=body,
395
399
  variables=variables,
396
- data=data or None,
397
400
  )
398
- return HtmlParseResult(document=document, fields=self.fields)
401
+ return HtmlParseResult(document=document, fields=self.fields, data=data or None)
399
402
 
400
403
  @staticmethod
401
404
  def _read(source: str | bytes | io.BytesIO) -> str:
@@ -183,7 +183,12 @@ class Variable:
183
183
 
184
184
  @dataclass
185
185
  class DocumentConfig:
186
- """顶层文档配置(设计师最终产出的页面 JSON)。"""
186
+ """顶层文档配置(设计师最终产出的页面 JSON)。
187
+
188
+ 配置与数据分离:本文档**只描述页面**(版式/元素/变量声明/PDF 配置),不含任何填写数据。
189
+ 数据一律由调用方在渲染时传入(``render_pdf(document, data)``,缺省视为空对象)。
190
+ 变量的默认值写在 ``variables[].default``(变量/元素自身的配置)里,不另设文档级 data。
191
+ """
187
192
 
188
193
  version: str = "1.0"
189
194
  page: PageConfig = field(default_factory=PageConfig)
@@ -191,7 +196,6 @@ class DocumentConfig:
191
196
  security: dict[str, Any] | None = None
192
197
  variables: list[Variable] = field(default_factory=list)
193
198
  body: list[Element] = field(default_factory=list)
194
- data: dict[str, Any] | None = None # 示例/默认数据(运行态由表单填写产生)
195
199
  html: dict[str, Any] | None = None # HTML 预览专有配置节
196
200
  pdf: dict[str, Any] | None = None # PDF 渲染专有配置节
197
201
  #: 文档元数据。标准键(title/author/subject/keywords/creator)写入 PDF 标准字段,
@@ -207,7 +211,6 @@ class DocumentConfig:
207
211
  raise SchemaError(message="文档配置必须是 JSON 对象")
208
212
  body = [Element.from_dict(item) for item in raw.get("body", []) or []]
209
213
  variables = [Variable.from_dict(item) for item in raw.get("variables", []) or []]
210
- data = raw.get("data")
211
214
  meta_raw = raw.get("meta")
212
215
  pdf_raw = raw.get("pdf")
213
216
  html_raw = raw.get("html")
@@ -217,7 +220,6 @@ class DocumentConfig:
217
220
  security=raw.get("security") if isinstance(raw.get("security"), dict) else None,
218
221
  variables=variables,
219
222
  body=body,
220
- data=data if isinstance(data, dict) else None,
221
223
  html=html_raw if isinstance(html_raw, dict) else None,
222
224
  pdf=pdf_raw if isinstance(pdf_raw, dict) else None,
223
225
  meta=meta_raw if isinstance(meta_raw, dict) else {},
@@ -230,6 +232,8 @@ class DocumentConfig:
230
232
  "variables",
231
233
  "page",
232
234
  "version",
235
+ # 顶层 ``data`` 不是配置字段(配置与数据分离):读过即丢,
236
+ # 也不能漏进 ``raw`` —— 否则 ``to_dict()`` 会把它吐回来,脏数据复活。
233
237
  "data",
234
238
  "html",
235
239
  "pdf",
@@ -247,8 +251,6 @@ class DocumentConfig:
247
251
  }
248
252
  if self.variables:
249
253
  out["variables"] = [v.to_dict() for v in self.variables]
250
- if self.data is not None:
251
- out["data"] = self.data
252
254
  if self.html:
253
255
  out["html"] = self.html
254
256
  if self.pdf:
@@ -284,16 +286,6 @@ class DocumentConfig:
284
286
  page["footerDistance"] = self.page.footerDistance
285
287
  return page
286
288
 
287
- def default_data(self) -> dict[str, Any]:
288
- """合并默认数据:显式 data 优先,其次变量声明的 default。"""
289
- data: dict[str, Any] = {}
290
- for variable in self.variables:
291
- if variable.default is not None:
292
- data[variable.name] = variable.default
293
- if self.data:
294
- data.update(self.data)
295
- return data
296
-
297
289
  def widget_elements(self) -> list[Element]:
298
290
  """文档所有数据控件节点(填写表单据此生成)。"""
299
291
  return collect_widgets(self.body)
@@ -36,7 +36,14 @@ from pdfwright.render.codes import (
36
36
  DEFAULT_QR_SIZE,
37
37
  code_png,
38
38
  )
39
- from pdfwright.render.markup import parse_markup, parse_style_attr, strip_markup
39
+ from pdfwright.render.markup import (
40
+ clamp_css_font_size,
41
+ clamp_font_size,
42
+ is_safe_href,
43
+ parse_markup,
44
+ parse_style_attr,
45
+ strip_markup,
46
+ )
40
47
  from pdfwright.render.table import cell_regions, grid_placements
41
48
  from pdfwright.security import ensure_image_size, ensure_local_path
42
49
  from pdfwright.template.engine import TemplateEngine
@@ -74,8 +81,6 @@ _SAFE_CSS_PROPS = {
74
81
  "text-decoration",
75
82
  }
76
83
 
77
- _SAFE_URL_SCHEMES = ("http://", "https://", "mailto:", "/", "#")
78
-
79
84
 
80
85
  def markup_to_html(markup: str | None) -> str:
81
86
  """内联标记 → HTML(文本转义;``<font face size color>`` 转成 span 内联样式)。
@@ -104,7 +109,7 @@ def markup_to_html(markup: str | None) -> str:
104
109
  continue
105
110
  if tag == "a":
106
111
  href = str(token.attrs.get("href") or "")
107
- if href and not href.lower().startswith(_SAFE_URL_SCHEMES):
112
+ if not is_safe_href(href):
108
113
  href = ""
109
114
  attrs = f' href="{_html.escape(href)}"' if href else ""
110
115
  parts.append(f"<a{attrs}>")
@@ -113,8 +118,10 @@ def markup_to_html(markup: str | None) -> str:
113
118
  styles = []
114
119
  parsed = parse_style_attr(token.attrs.get("style", ""))
115
120
  for prop, value in parsed.items():
116
- if prop in _SAFE_CSS_PROPS:
117
- styles.append(f"{prop}:{value}")
121
+ if prop not in _SAFE_CSS_PROPS:
122
+ continue
123
+ raw = clamp_css_font_size(value) if prop == "font-size" else value
124
+ styles.append(f"{prop}:{raw}")
118
125
  if parsed.get("font-family"):
119
126
  family = css_font_family(parsed["font-family"].split(",")[0].strip())
120
127
  if family:
@@ -134,7 +141,7 @@ def markup_to_html(markup: str | None) -> str:
134
141
  size = token.attrs.get("size")
135
142
  if size:
136
143
  with contextlib.suppress(ValueError):
137
- styles.append(f"font-size:{float(size):.1f}pt")
144
+ styles.append(f"font-size:{clamp_font_size(float(size)):.1f}pt")
138
145
  if token.attrs.get("color"):
139
146
  styles.append(f"color:{token.attrs['color']}")
140
147
  if token.attrs.get("backcolor"):
@@ -8,6 +8,8 @@
8
8
  - 渲染器各自决定如何落地(ReportLab 用自己的 ``<font>``;HTML 把 ``size`` 换算成
9
9
  ``font-size:Npt``,因为 HTML 的 ``<font size>`` 是 1–7 档,语义完全不同);
10
10
  - 非白名单标签一律按普通文本处理(调用方负责转义),避免注入。
11
+ - ``<a href>`` 只放行 ``SAFE_URL_SCHEMES``(HTML 与 PDF 两侧一致,`javascript:`/`data:` 丢弃);
12
+ ``<font size>`` / ``span{font-size}`` 夹到 ``MIN/MAX_MARKUP_FONT_SIZE``(挡病态字号撑爆版面)。
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -52,6 +54,42 @@ PAIRED_TAGS = ALLOWED_TAGS - {"br"}
52
54
  #: span/mark 的默认高亮色
53
55
  DEFAULT_MARK_COLOR = "#fffbb8"
54
56
 
57
+ #: `<a href>` 允许的 URL scheme(白名单:其余一律丢弃)。
58
+ #: 用**允许列表**而不是黑名单:任何归一化差异(大小写/空白/换行)只会误拦安全链接,
59
+ #: 不会放过 `javascript:` / `data:` / `vbscript:` 之类。
60
+ SAFE_URL_SCHEMES = ("http://", "https://", "mailto:", "/", "#")
61
+
62
+ #: 内联标记里的字号范围(pt):挡住 `size="5000"` 这类把版面撑爆的病态值。
63
+ #: 500pt ≈ 17.6cm 字高,正常排版(A4/A3 海报)远小于此。
64
+ MIN_MARKUP_FONT_SIZE = 1.0
65
+ MAX_MARKUP_FONT_SIZE = 500.0
66
+
67
+ #: CSS font-size 里的数值部分(保留单位)
68
+ _CSS_FONT_SIZE_RE = re.compile(r"(-?[\d.]+)")
69
+
70
+
71
+ def is_safe_href(href: str | None) -> bool:
72
+ """`<a href>` 是否可信(非空且 scheme 在白名单内)。"""
73
+ return bool(href) and str(href).lower().startswith(SAFE_URL_SCHEMES)
74
+
75
+
76
+ def clamp_font_size(size: float) -> float:
77
+ """把字号(pt)夹到合法范围。"""
78
+ return min(max(float(size), MIN_MARKUP_FONT_SIZE), MAX_MARKUP_FONT_SIZE)
79
+
80
+
81
+ def clamp_css_font_size(value: str) -> str:
82
+ """把 CSS ``font-size`` 的数值部分夹到合法范围(保留原单位)。"""
83
+ raw = value.strip()
84
+ match = _CSS_FONT_SIZE_RE.match(raw)
85
+ if not match:
86
+ return value
87
+ try:
88
+ size = float(match.group(1))
89
+ except ValueError:
90
+ return value
91
+ return f"{clamp_font_size(size):g}{raw[match.end() :]}"
92
+
55
93
 
56
94
  def parse_style_attr(value: str) -> dict[str, str]:
57
95
  """解析 ``style="color:red;font-size:12pt"`` → dict(键小写)。"""
@@ -109,4 +147,16 @@ def strip_markup(markup: str) -> str:
109
147
  return "".join(token.text for token in parse_markup(markup) if token.kind == "text")
110
148
 
111
149
 
112
- __all__ = ["ALLOWED_TAGS", "Token", "parse_markup", "strip_markup"]
150
+ __all__ = [
151
+ "ALLOWED_TAGS",
152
+ "MAX_MARKUP_FONT_SIZE",
153
+ "MIN_MARKUP_FONT_SIZE",
154
+ "SAFE_URL_SCHEMES",
155
+ "Token",
156
+ "clamp_css_font_size",
157
+ "clamp_font_size",
158
+ "is_safe_href",
159
+ "parse_markup",
160
+ "parse_style_attr",
161
+ "strip_markup",
162
+ ]
@@ -64,6 +64,8 @@ from pdfwright.render.codes import (
64
64
  from pdfwright.render.markup import (
65
65
  DEFAULT_MARK_COLOR,
66
66
  REPORTLAB_TAGS,
67
+ clamp_font_size,
68
+ is_safe_href,
67
69
  parse_markup,
68
70
  parse_style_attr,
69
71
  strip_markup,
@@ -572,7 +574,9 @@ class PdfRenderer:
572
574
  state["family"] = "mono"
573
575
  elif tag == "a":
574
576
  stack.append(("link", state["link"]))
575
- state["link"] = token.attrs.get("href") or None
577
+ href = token.attrs.get("href")
578
+ # scheme 白名单:`javascript:` 之类不得写进 PDF 的 /URI 动作(与 HTML 侧一致)
579
+ state["link"] = href if is_safe_href(href) else None
576
580
  elif tag == "span":
577
581
  stack.append(("span", dict(state)))
578
582
  self._apply_style_attr(state, token.attrs.get("style", ""))
@@ -582,7 +586,7 @@ class PdfRenderer:
582
586
  state["family"] = token.attrs["face"]
583
587
  if token.attrs.get("size"):
584
588
  with contextlib.suppress(ValueError):
585
- state["size"] = float(token.attrs["size"])
589
+ state["size"] = clamp_font_size(float(token.attrs["size"]))
586
590
  if token.attrs.get("color"):
587
591
  state["color"] = token.attrs["color"]
588
592
  if token.attrs.get("backcolor"):
@@ -597,7 +601,7 @@ class PdfRenderer:
597
601
  size = parsed.get("font-size", "")
598
602
  match = re.match(r"([\d.]+)", size) if size else None
599
603
  if match:
600
- state["size"] = float(match.group(1))
604
+ state["size"] = clamp_font_size(float(match.group(1)))
601
605
  if parsed.get("color"):
602
606
  state["color"] = parsed["color"]
603
607
  if parsed.get("background-color"):
@@ -1,4 +1,4 @@
1
- """模板上下文:把 data(用户填写数据)与变量默认值合并为 jinja2 渲染的作用域。"""
1
+ """模板上下文:把填写数据(data)与变量默认值合并为 jinja2 渲染的作用域。"""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -14,14 +14,15 @@ def build_context(
14
14
  ) -> dict[str, Any]:
15
15
  """构建渲染上下文(顶层变量直接可访问)。
16
16
 
17
- 合并顺序(低到高优先级):变量默认值 < 用户数据 data < extra(循环变量等)。
17
+ 配置与数据分离:数据**只**从 ``data`` 参数进(未传 = 空对象),
18
+ ``document`` 不携带任何数据。
19
+
20
+ 合并顺序(低到高优先级):变量默认值(``variables[].default``)< 填写数据 < extra(循环变量等)。
18
21
  """
19
22
  context: dict[str, Any] = {}
20
23
  for variable in document.variables:
21
24
  if variable.default is not None:
22
25
  context[variable.name] = variable.default
23
- if document.data:
24
- context.update(document.data)
25
26
  if data:
26
27
  context.update(data)
27
28
  if extra:
@@ -71,9 +71,9 @@ def test_html_table_with_loop() -> None:
71
71
  # 循环内 {{item.*}} 不登记字段,循环外 {{total}} 登记
72
72
  names = {f.name for f in result.fields}
73
73
  assert names == {"total"}
74
- # 示例数据可渲染
75
- assert result.document.data
76
- assert result.document.data["rows"]
74
+ # 示例数据(与配置分离,挂在 ParseResult 上)可渲染
75
+ assert result.data
76
+ assert result.data["rows"]
77
77
 
78
78
 
79
79
  def test_html_security_and_sanitizing() -> None:
@@ -0,0 +1,131 @@
1
+ """内联富文本标记的**安全硬化**:链接 scheme 白名单 + 字号 clamp。
2
+
3
+ 设计要点(同 `render/markup.py`):`<a href>` 只放行允许的 scheme(HTML 与 PDF 一致,
4
+ `javascript:` 一律丢弃);`<font size>` / `span{font-size}` 夹到合法范围,挡住撑爆版面的病态值。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+
11
+ import pytest
12
+ from pypdf import PdfReader
13
+
14
+ from pdfwright import DocumentConfig
15
+ from pdfwright.render.html import markup_to_html, render_html
16
+ from pdfwright.render.markup import (
17
+ MAX_MARKUP_FONT_SIZE,
18
+ MIN_MARKUP_FONT_SIZE,
19
+ clamp_css_font_size,
20
+ clamp_font_size,
21
+ is_safe_href,
22
+ )
23
+ from pdfwright.render.pdf import render_pdf
24
+
25
+
26
+ def _doc(text: str) -> DocumentConfig:
27
+ return DocumentConfig.from_dict({"body": [{"type": "paragraph", "text": text}]})
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # scheme 白名单
32
+ # --------------------------------------------------------------------------- #
33
+ @pytest.mark.parametrize(
34
+ "href",
35
+ ["http://a.example", "https://a.example", "mailto:a@b.c", "/rel/path", "#anchor"],
36
+ )
37
+ def test_is_safe_href_allows(href: str) -> None:
38
+ assert is_safe_href(href)
39
+
40
+
41
+ @pytest.mark.parametrize(
42
+ "href",
43
+ [
44
+ None,
45
+ "",
46
+ "javascript:alert(1)",
47
+ "JAVASCRIPT:alert(1)",
48
+ " javascript:alert(1)", # 前置空白归一化差异只应误拦,不能放过
49
+ "java\nscript:alert(1)",
50
+ "data:text/html,<script>1</script>",
51
+ "vbscript:msgbox(1)",
52
+ "file:///etc/passwd",
53
+ ],
54
+ )
55
+ def test_is_safe_href_rejects(href: str | None) -> None:
56
+ assert not is_safe_href(href)
57
+
58
+
59
+ def test_html_drops_unsafe_href_keeps_safe() -> None:
60
+ html = markup_to_html('<a href="javascript:alert(1)">坏</a><a href="https://ok.example">好</a>')
61
+ assert "javascript:" not in html
62
+ assert html.startswith("<a>坏</a>") # scheme 不合法 → 剥成无 href 的 <a>
63
+ assert 'href="https://ok.example"' in html
64
+
65
+
66
+ def test_pdf_drops_unsafe_href_keeps_safe() -> None:
67
+ """PDF 的 /URI 动作必须与 HTML 同样过滤(此前 javascript: 会原样写进去)。"""
68
+ payload = render_pdf(
69
+ _doc('<a href="javascript:alert(1)">坏</a> <a href="https://ok.example">好</a>'), {}
70
+ )
71
+ uris = [
72
+ annot.get_object().get("/A").get_object().get("/URI")
73
+ for page in PdfReader(io.BytesIO(payload)).pages
74
+ for annot in (page.get("/Annots") or [])
75
+ ]
76
+ assert "https://ok.example" in uris
77
+ assert all(not str(uri).lower().startswith("javascript:") for uri in uris)
78
+
79
+
80
+ # --------------------------------------------------------------------------- #
81
+ # 字号 clamp
82
+ # --------------------------------------------------------------------------- #
83
+ def test_clamp_font_size_bounds() -> None:
84
+ assert clamp_font_size(5000) == MAX_MARKUP_FONT_SIZE
85
+ assert clamp_font_size(0) == MIN_MARKUP_FONT_SIZE
86
+ assert clamp_font_size(-12) == MIN_MARKUP_FONT_SIZE
87
+ assert clamp_font_size(14) == 14
88
+
89
+
90
+ @pytest.mark.parametrize(
91
+ ("raw", "expected"),
92
+ [
93
+ ("5000pt", f"{MAX_MARKUP_FONT_SIZE:g}pt"),
94
+ ("0.0001pt", f"{MIN_MARKUP_FONT_SIZE:g}pt"),
95
+ ("-3pt", f"{MIN_MARKUP_FONT_SIZE:g}pt"),
96
+ ("14pt", "14pt"),
97
+ ("16px", "16px"), # 保留原单位
98
+ ("abc", "abc"), # 非数值原样返回
99
+ ],
100
+ )
101
+ def test_clamp_css_font_size(raw: str, expected: str) -> None:
102
+ assert clamp_css_font_size(raw) == expected
103
+
104
+
105
+ def test_html_clamps_font_size() -> None:
106
+ html = markup_to_html('<font size="5000">甲</font><font size="0.0001">乙</font>')
107
+ assert f"font-size:{MAX_MARKUP_FONT_SIZE:.1f}pt" in html
108
+ assert f"font-size:{MIN_MARKUP_FONT_SIZE:.1f}pt" in html
109
+ assert "5000.0pt" not in html
110
+
111
+
112
+ def test_html_clamps_span_font_size() -> None:
113
+ html = markup_to_html('<span style="font-size:9999pt;color:red">甲</span>')
114
+ assert f"font-size:{MAX_MARKUP_FONT_SIZE:g}pt" in html
115
+ assert "color:red" in html
116
+
117
+
118
+ def test_pdf_clamps_huge_font_size_without_breaking() -> None:
119
+ """超大字号被夹住,渲染仍然成功(不撑爆版面、不抛异常)。"""
120
+ payload = render_pdf(_doc('<font size="5000">甲</font><font size="0.0001">乙</font>'), {})
121
+ assert payload[:4] == b"%PDF"
122
+ text = "".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(payload)).pages)
123
+ assert "甲" in text
124
+ assert "乙" in text
125
+
126
+
127
+ def test_render_html_escapes_unknown_tags_as_text() -> None:
128
+ """未知标签按文本输出(转义),不是丢弃、更不是执行。"""
129
+ html = render_html(_doc("<script>alert(1)</script>正文"), {})
130
+ assert "<script>" not in html
131
+ assert "&lt;script&gt;" in html
@@ -105,15 +105,29 @@ def test_build_context_merge_order() -> None:
105
105
  doc = DocumentConfig.from_dict(
106
106
  {
107
107
  "variables": [{"name": "a", "default": 1}, {"name": "b", "default": 2}],
108
- "data": {"b": 20},
109
108
  }
110
109
  )
111
110
  ctx = build_context(doc, {"b": 200}, extra={"loop": "x"})
112
- assert ctx["a"] == 1 # 默认值
113
- assert ctx["b"] == 200 # 填写数据覆盖默认与示例
111
+ assert ctx["a"] == 1 # 变量默认值
112
+ assert ctx["b"] == 200 # 填写数据覆盖默认值
114
113
  assert ctx["loop"] == "x"
115
114
 
116
115
 
116
+ def test_document_data_is_not_a_config_field() -> None:
117
+ """配置与数据分离:顶层 ``data`` 不是配置字段,既不入上下文也不 round-trip 回来。"""
118
+ raw = {
119
+ "variables": [{"name": "b", "default": 2}],
120
+ "data": {"b": 20},
121
+ "body": [{"type": "paragraph", "text": "{{ b }}"}],
122
+ }
123
+ doc = DocumentConfig.from_dict(raw)
124
+ assert not hasattr(doc, "data"), "DocumentConfig 不应有 data 字段"
125
+ # 未传数据 → 空对象:只取变量默认值,顶层 data 被忽略
126
+ assert build_context(doc) == {"b": 2}
127
+ # round-trip 不把 data 吐回来
128
+ assert "data" not in doc.to_dict()
129
+
130
+
117
131
  def test_data_keys_shadow_python_dict_methods() -> None:
118
132
  """模板取值必须**先按键**:数据里叫 items/values/item 的字段不能被 dict 方法截胡。
119
133
 
File without changes
File without changes
File without changes
File without changes
File without changes