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.
- {pdfwright-0.2.1 → pdfwright-0.2.2}/PKG-INFO +1 -1
- {pdfwright-0.2.1 → pdfwright-0.2.2}/pyproject.toml +1 -1
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/__init__.py +1 -1
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/docx.py +8 -2
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/html.py +6 -3
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/document.py +8 -16
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/html/__init__.py +14 -7
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/markup.py +51 -1
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/pdf.py +7 -3
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/context.py +5 -4
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_html_converter.py +3 -3
- pdfwright-0.2.2/tests/test_render_markup.py +131 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_template.py +17 -3
- {pdfwright-0.2.1 → pdfwright-0.2.2}/.gitignore +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/README.md +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/contract.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/cooperation_contract.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/daily_report.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/feature_tour.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/font_mixing.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/generate_all.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/generate_demo.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/invoice.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/output.html +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/output.pdf +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/path_security.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/sample_document.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/service_contract.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/examples/staff_form.json +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/batch.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/converters/xlsx.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/errors.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/fallback.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/registry.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/scanner.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/sfnt.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/fonts/system_fonts.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/borders.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/constants.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/elements.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/models/style.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/postprocess/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/postprocess/metadata.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/postprocess/pdfa_verify.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/py.typed +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/base.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/codes.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/pdfa.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/render/table.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/security.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/engine.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/filters.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/template/format.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/utils.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/src/pdfwright/validate/__init__.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_docx_converter.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_examples.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_fonts.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_models.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_render_html.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_render_pdf.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_render_table.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_security.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_shared_vectors.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_validate.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/tests/test_xlsx_converter.py +0 -0
- {pdfwright-0.2.1 → pdfwright-0.2.2}/uv.lock +0 -0
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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__ = [
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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.
|
|
76
|
-
assert result.
|
|
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 "<script>" 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|