pdfwright-web 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 (68) hide show
  1. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/PKG-INFO +1 -1
  2. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/package.json +1 -1
  3. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/App.vue +3 -3
  4. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/api/client.ts +4 -0
  5. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/JsonPanel.vue +4 -1
  6. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/fill/FillView.vue +2 -2
  7. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/expand.ts +4 -2
  8. pdfwright_web-0.2.2/frontend/src/lib/markup.spec.ts +58 -0
  9. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/markup.ts +20 -2
  10. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/store/designer.spec.ts +37 -0
  11. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/store/designer.ts +17 -3
  12. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/types/schema.ts +1 -1
  13. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/pyproject.toml +1 -1
  14. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/__init__.py +1 -1
  15. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/app.py +1 -1
  16. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/convert.py +3 -0
  17. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/examples.py +5 -0
  18. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/schemas.py +14 -4
  19. pdfwright_web-0.2.1/src/pdfwright_web/static/assets/index-CGEB_naM.js → pdfwright_web-0.2.2/src/pdfwright_web/static/assets/index-BgTOK7V4.js +16 -16
  20. pdfwright_web-0.2.1/src/pdfwright_web/static/assets/index-z4cHEGId.css → pdfwright_web-0.2.2/src/pdfwright_web/static/assets/index-DrWAVhY-.css +1 -1
  21. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/static/index.html +2 -2
  22. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/tests/test_api.py +3 -2
  23. pdfwright_web-0.2.1/frontend/src/lib/markup.spec.ts +0 -14
  24. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/.gitignore +0 -0
  25. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/README.md +0 -0
  26. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/env.d.ts +0 -0
  27. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/eslint.config.js +0 -0
  28. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/index.html +0 -0
  29. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/pnpm-lock.yaml +0 -0
  30. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/components/BoxInput.spec.ts +0 -0
  31. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/components/BoxInput.vue +0 -0
  32. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/components/UnitInput.vue +0 -0
  33. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/CanvasArea.vue +0 -0
  34. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/ComponentPalette.vue +0 -0
  35. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/ElementTree.vue +0 -0
  36. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/ElementTreeNode.vue +0 -0
  37. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/PropsPanel.spec.ts +0 -0
  38. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/designer/PropsPanel.vue +0 -0
  39. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/clone.ts +0 -0
  40. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/download.ts +0 -0
  41. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/expand.spec.ts +0 -0
  42. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/fonts.ts +0 -0
  43. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/format.spec.ts +0 -0
  44. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/format.ts +0 -0
  45. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/table.spec.ts +0 -0
  46. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/table.ts +0 -0
  47. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/template.ts +0 -0
  48. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/tree.ts +0 -0
  49. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/units.spec.ts +0 -0
  50. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/lib/units.ts +0 -0
  51. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/main.ts +0 -0
  52. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/renderer/ElementRenderer.spec.ts +0 -0
  53. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/renderer/ElementRenderer.vue +0 -0
  54. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/renderer/InlineText.vue +0 -0
  55. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/renderer/WatermarkLayer.vue +0 -0
  56. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/src/styles/main.css +0 -0
  57. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/tsconfig.json +0 -0
  58. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/frontend/vite.config.ts +0 -0
  59. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/concurrency.py +0 -0
  60. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/meta.py +0 -0
  61. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/__init__.py +0 -0
  62. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/batch.py +0 -0
  63. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/codes.py +0 -0
  64. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/meta.py +0 -0
  65. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/options.py +0 -0
  66. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/render.py +0 -0
  67. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/routers/verify.py +0 -0
  68. {pdfwright_web-0.2.1 → pdfwright_web-0.2.2}/src/pdfwright_web/security_defaults.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pdfwright-web
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: pdfwright 的无状态 Web 服务:可视化配置编辑器 + HTML/PDF 预览 + 对外渲染 API(不存储任何数据与内容)
5
5
  License: Proprietary
6
6
  Requires-Python: >=3.12
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pdfwright-designer",
3
3
  "private": true,
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -74,7 +74,7 @@ async function loadExamples(): Promise<void> {
74
74
  function loadExample(id: string): void {
75
75
  const item = examples.value.find((example) => example.id === id)
76
76
  if (!item) return
77
- importSchema(item.schema)
77
+ importSchema(item.schema, item.data ?? {})
78
78
  ElMessage.success(`已加载示例:${item.title}`)
79
79
  }
80
80
 
@@ -113,7 +113,7 @@ async function importHtml(): Promise<void> {
113
113
  htmlBusy.value = true
114
114
  try {
115
115
  const result = await api.convertHtml(htmlSource.value)
116
- importSchema(result.document)
116
+ importSchema(result.document, result.data ?? {})
117
117
  ElMessage.success(`已转换:${result.fields.length} 个字段`)
118
118
  htmlDialog.value = false
119
119
  } catch (error) {
@@ -128,7 +128,7 @@ async function onOfficeFile(event: Event, kind: 'docx' | 'xlsx'): Promise<void>
128
128
  if (!file) return
129
129
  try {
130
130
  const result = kind === 'docx' ? await api.convertDocx(file) : await api.convertXlsx(file)
131
- importSchema(result.document)
131
+ importSchema(result.document, result.data ?? {})
132
132
  ElMessage.success(`已转换:${result.fields.length} 个字段`)
133
133
  } catch (error) {
134
134
  ElMessage.error(`转换失败:${(error as Error).message}`)
@@ -27,11 +27,15 @@ export interface ExampleItem {
27
27
  variables: number
28
28
  blocks: number
29
29
  schema: DocumentSchema
30
+ /** 示例数据(与 schema 分离:schema 不再内嵌 data) */
31
+ data?: FormData | null
30
32
  }
31
33
 
32
34
  export interface ConvertResponse {
33
35
  document: DocumentSchema
34
36
  fields: { name: string; label: string; kind: string }[]
37
+ /** 转换时自动生成的示例数据(与 schema 分离,单独下发) */
38
+ data?: FormData | null
35
39
  }
36
40
 
37
41
  async function jsonRequest<T>(url: string, init?: RequestInit): Promise<T> {
@@ -17,7 +17,10 @@ watch(() => state.mode, sync)
17
17
 
18
18
  function applySchema(): void {
19
19
  try {
20
- importSchema(JSON.parse(schemaText.value) as DocumentSchema)
20
+ const parsed = JSON.parse(schemaText.value) as DocumentSchema
21
+ // 配置 JSON 不含数据(data 是运行态):应用配置时保留当前示例数据,
22
+ // 否则编辑一下 schema 就顺手把「数据」tab / 右栏里的 mock 清空了。
23
+ importSchema(parsed, state.sampleData)
21
24
  ElMessage.success('页面配置已应用')
22
25
  void refreshPreview()
23
26
  } catch (error) {
@@ -26,13 +26,13 @@ const margin = computed(() => {
26
26
  return boxToPt(raw)
27
27
  })
28
28
 
29
- /** 初值:变量默认值 + 示例数据 */
29
+ /** 初值:变量默认值 + 导入时携带的示例数据快照(填表过程中的编辑不回写快照,故「重置」可恢复)。 */
30
30
  function initialData(): Record<string, unknown> {
31
31
  const out: Record<string, unknown> = {}
32
32
  for (const variable of state.schema.variables ?? []) {
33
33
  if (variable.default !== undefined && variable.default !== null) out[variable.name] = variable.default
34
34
  }
35
- return { ...out, ...(state.schema.data ?? {}) }
35
+ return { ...out, ...(state.sampleData ?? {}) }
36
36
  }
37
37
 
38
38
  const scope = computed(() => buildContext(state.schema, { ...state.data, ...formData }))
@@ -10,7 +10,10 @@ export interface ResolvedValue {
10
10
  hasValue: boolean
11
11
  }
12
12
 
13
- /** 构建作用域:变量默认值 < schema.data < 运行态 data < extra。 */
13
+ /** 构建作用域:变量默认值 < 填写数据 data < extra。
14
+ *
15
+ * 配置与数据分离:数据只从 ``data`` 参数进,schema 不携带任何示例/mock 数据。
16
+ */
14
17
  export function buildContext(
15
18
  schema: DocumentSchema,
16
19
  data?: Record<string, unknown> | null,
@@ -22,7 +25,6 @@ export function buildContext(
22
25
  scope[variable.name] = variable.default
23
26
  }
24
27
  }
25
- if (schema.data) Object.assign(scope, schema.data)
26
28
  if (data) Object.assign(scope, data)
27
29
  if (extra) Object.assign(scope, extra)
28
30
  return scope
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { markupToHtml, plainText } from './markup'
4
+
5
+
6
+ describe('plainText', () => {
7
+ it('剥离内联标记但保留 {{ 变量 }}', () => {
8
+ expect(plainText('<font color="#000" size="10.5" face="cn-song">一、项目部</font>')).toBe('一、项目部')
9
+ expect(plainText('{{project_name}}日报')).toBe('{{project_name}}日报')
10
+ expect(plainText('<b>{{a}}</b> 与 <i>b</i>')).toBe('{{a}} 与 b')
11
+ expect(plainText('<font face="x">甲<br/>乙</font>')).toBe('甲 乙')
12
+ expect(plainText(null)).toBe('')
13
+ })
14
+ })
15
+
16
+ describe('markupToHtml:安全硬化', () => {
17
+ it('<a href> 只放行 scheme 白名单(javascript: 被剥成无 href 的 <a>)', () => {
18
+ const html = markupToHtml('<a href="javascript:alert(1)">坏</a><a href="https://ok.example">好</a>', {
19
+ mode: 'preview',
20
+ })
21
+ expect(html).not.toContain('javascript:')
22
+ expect(html).toContain('<a>坏</a>')
23
+ expect(html).toContain('href="https://ok.example"')
24
+ })
25
+
26
+ it('文本转义:未知标签按文本输出,不执行', () => {
27
+ const html = markupToHtml('<script>alert(1)</script>正文', { mode: 'preview' })
28
+ expect(html).not.toContain('<script>')
29
+ expect(html).toContain('&lt;script&gt;')
30
+ })
31
+
32
+ it('未知属性被忽略(onmouseover 无法注入)', () => {
33
+ const html = markupToHtml('<font color="red" onmouseover="x">甲</font>', { mode: 'preview' })
34
+ expect(html).not.toContain('onmouseover')
35
+ expect(html).toContain('color:red')
36
+ })
37
+
38
+ it('字号夹到合法范围(font size 与 span font-size 同规则)', () => {
39
+ expect(markupToHtml('<font size="5000">甲</font>', { mode: 'preview' })).toContain(
40
+ 'font-size:500pt',
41
+ )
42
+ expect(markupToHtml('<font size="0.0001">甲</font>', { mode: 'preview' })).toContain(
43
+ 'font-size:1pt',
44
+ )
45
+ expect(
46
+ markupToHtml('<span style="font-size:9999pt;color:red">甲</span>', { mode: 'preview' }),
47
+ ).toContain('font-size:500pt')
48
+ })
49
+
50
+ it('占位符值按文本转义(预览态值不成为标记)', () => {
51
+ const html = markupToHtml('{{ v }}', {
52
+ mode: 'preview',
53
+ scope: { v: '<b>注入</b>' },
54
+ })
55
+ expect(html).not.toContain('<b>')
56
+ expect(html).toContain('&lt;b&gt;注入&lt;/b&gt;')
57
+ })
58
+ })
@@ -39,6 +39,9 @@ const SAFE_CSS_PROPS = new Set([
39
39
  'text-decoration',
40
40
  ])
41
41
  const SAFE_URL_SCHEMES = ['http://', 'https://', 'mailto:', '/', '#']
42
+ // 内联标记字号范围(pt):与后端 `render/markup.py` 同值,挡住 `size="5000"` 这类病态值
43
+ const MIN_MARKUP_FONT_SIZE = 1
44
+ const MAX_MARKUP_FONT_SIZE = 500
42
45
  const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>/g
43
46
  const ATTR_RE = /([a-zA-Z-]+)\s*=\s*"([^"]*)"/g
44
47
  const PLACEHOLDER_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_.\]]*)\s*\}\}/g
@@ -78,6 +81,20 @@ function escapeHtml(value: string): string {
78
81
  .replace(/"/g, '&quot;')
79
82
  }
80
83
 
84
+ /** 字号夹到合法范围(与后端 `clamp_font_size` 同规则)。 */
85
+ function clampFontSize(size: number): number {
86
+ return Math.min(Math.max(size, MIN_MARKUP_FONT_SIZE), MAX_MARKUP_FONT_SIZE)
87
+ }
88
+
89
+ /** CSS `font-size` 的数值部分夹到合法范围(保留原单位)。 */
90
+ function clampCssFontSize(value: string): string {
91
+ const match = /^\s*(-?[\d.]+)/.exec(value)
92
+ if (!match) return value
93
+ const size = Number(match[1])
94
+ if (!Number.isFinite(size)) return value
95
+ return `${clampFontSize(size)}${value.slice(match[0].trimEnd().length)}`
96
+ }
97
+
81
98
  /** 占位符显示名:绝对路径里的数组下标压缩成 `[]`(person.0.person.0.name → person[].person[].name)。 */
82
99
  export function displayName(name: string): string {
83
100
  return name.replace(/\.\d+(?=\.|$)/g, '[]')
@@ -135,7 +152,8 @@ export function markupToHtml(markup: string | null | undefined, options: MarkupO
135
152
  const parsed = parseStyleAttr(attrsOf(match[3] ?? '').style ?? '')
136
153
  const styles: string[] = []
137
154
  for (const [prop, value] of Object.entries(parsed)) {
138
- if (SAFE_CSS_PROPS.has(prop)) styles.push(`${prop}:${value}`)
155
+ if (!SAFE_CSS_PROPS.has(prop)) continue
156
+ styles.push(`${prop}:${prop === 'font-size' ? clampCssFontSize(value) : value}`)
139
157
  }
140
158
  parts.push(styles.length ? `<span style="${styles.join(';')}">` : '<span>')
141
159
  } else if (PASSTHROUGH_TAGS[tag]) {
@@ -146,7 +164,7 @@ export function markupToHtml(markup: string | null | undefined, options: MarkupO
146
164
  const family = attrs.face ? fontFamilyCss(attrs.face) : undefined
147
165
  if (family) styles.push(`font-family:${family}`)
148
166
  const size = Number(attrs.size)
149
- if (Number.isFinite(size) && size > 0) styles.push(`font-size:${size}pt`)
167
+ if (Number.isFinite(size)) styles.push(`font-size:${clampFontSize(size)}pt`)
150
168
  if (attrs.color) styles.push(`color:${attrs.color}`)
151
169
  if (attrs.backcolor) styles.push(`background-color:${attrs.backcolor}`)
152
170
  const spacing = Number(attrs.spacing)
@@ -25,6 +25,7 @@ import {
25
25
  duplicateSelected,
26
26
  ensureDrop,
27
27
  exportSchema,
28
+ importSchema,
28
29
  moveSelected,
29
30
  moveNodeTo,
30
31
  removeSelected,
@@ -68,6 +69,7 @@ function reset(): void {
68
69
  state.selectedId = null
69
70
  state.components = { groups: [], components: [headingMeta, sectionMeta], styleProps: [], fontSizes: { cn: [] }, pageSizes: [], fontFamilies: [] }
70
71
  state.data = {}
72
+ state.sampleData = {}
71
73
  state.issues = []
72
74
  state.history = []
73
75
  state.historyIndex = -1
@@ -545,3 +547,38 @@ describe('档案模板预设 / 变量自动补全', () => {
545
547
  expect(syncVariables()).toBe(0)
546
548
  })
547
549
  })
550
+
551
+ describe('导入导出:配置只服务渲染', () => {
552
+ beforeEach(() => {
553
+ reset()
554
+ vi.spyOn(api, 'validateDocument').mockResolvedValue({ ok: true, issues: [] })
555
+ })
556
+
557
+ it('导出的页面配置不含 data(配置与数据分离)', () => {
558
+ const exported = exportSchema()
559
+ expect('data' in exported).toBe(false)
560
+ expect(JSON.stringify(exported)).not.toContain('"data"')
561
+ })
562
+
563
+ it('导入无 pdf 节的配置时 PDF/A 档位落地为默认 2b', () => {
564
+ importSchema({ version: '1.0', body: [{ type: 'paragraph', text: '正文' }] }, { a: 1 })
565
+ expect(exportSchema().pdf?.pdfa).toBe('2b')
566
+ // 示例数据与配置分离:只进运行态,供设计器内 mock 预览
567
+ expect(state.data).toEqual({ a: 1 })
568
+ expect(state.sampleData).toEqual({ a: 1 })
569
+ expect('data' in exportSchema()).toBe(false)
570
+ })
571
+
572
+ it('导入显式 pdfa=false 时不被默认值覆盖', () => {
573
+ importSchema({ version: '1.0', body: [], pdf: { pdfa: false, numericFont: 'serif' } })
574
+ const pdf = exportSchema().pdf
575
+ expect(pdf?.pdfa).toBe(false)
576
+ expect(pdf?.numericFont).toBe('serif')
577
+ })
578
+
579
+ it('不传 data 时运行态数据为空对象', () => {
580
+ importSchema({ version: '1.0', body: [] })
581
+ expect(state.data).toEqual({})
582
+ expect(state.sampleData).toEqual({})
583
+ })
584
+ })
@@ -62,6 +62,8 @@ export const DEFAULT_SCHEMA: DocumentSchema = {
62
62
  interface DesignerState {
63
63
  schema: DocumentSchema
64
64
  data: FormData
65
+ /** 导入/转换带来的示例数据快照(填写态「重置」的不可变初值;不进 schema、不导出) */
66
+ sampleData: FormData
65
67
  selectedId: string | null
66
68
  components: ComponentsPayload | null
67
69
  fonts: string[]
@@ -86,6 +88,7 @@ interface DesignerState {
86
88
  export const state = reactive<DesignerState>({
87
89
  schema: deepClone(DEFAULT_SCHEMA),
88
90
  data: {},
91
+ sampleData: {},
89
92
  selectedId: null,
90
93
  components: null,
91
94
  fonts: [],
@@ -987,6 +990,11 @@ export function setDataValue(name: string, value: unknown): void {
987
990
  }
988
991
 
989
992
  // ---- 导入导出 ----
993
+ /** 导出「页面配置」:只服务 PDF / 页面渲染。
994
+ *
995
+ * schema 本身就不含示例/mock 数据(配置与数据分离,见 ``importSchema``):这里只剔除
996
+ * 编辑器内部使用的 ``_id``。设计器的 mock 数据存在运行态 ``state.data``,不随配置分发。
997
+ */
990
998
  export function exportSchema(): DocumentSchema {
991
999
  return {
992
1000
  ...state.schema,
@@ -994,16 +1002,22 @@ export function exportSchema(): DocumentSchema {
994
1002
  }
995
1003
  }
996
1004
 
997
- export function importSchema(schema: DocumentSchema): void {
1005
+ /** 导入页面配置。``data`` 是与配置**分离**的示例/填写数据(缺省为空),不会写进 schema。 */
1006
+ export function importSchema(schema: DocumentSchema, data?: FormData | null): void {
998
1007
  state.schema = {
999
1008
  ...schema,
1009
+ // PDF/A 档位显式落地:属性面板在 `pdf` 缺失时会 UI 兜底显示 2b
1010
+ // (PropsPanel.pdfaLevel),若 JSON 不写出来,「标准」就只存在于隐式默认里
1011
+ // (导出看不到、与后端 effective_pdf_options 的默认也可能悄悄分叉)。
1012
+ pdf: { pdfa: '2b', ...(schema.pdf ?? {}) },
1000
1013
  body: schema.body ?? [],
1001
1014
  variables: schema.variables ?? [],
1002
1015
  }
1003
1016
  ensureIds(state.schema.body)
1004
1017
  state.selectedId = null
1005
- // 导入的配置常自带示例数据(如 Word/Excel 转换结果)——保留它,预览才有内容
1006
- state.data = schema.data ? deepClone(schema.data) : {}
1018
+ // 示例数据(Word/Excel/HTML 转换器或示例文件带来)与配置分离:只进运行态,供预览使用
1019
+ state.sampleData = deepClone(data ?? {})
1020
+ state.data = deepClone(state.sampleData)
1007
1021
  pushHistory()
1008
1022
  void refreshPreview()
1009
1023
  }
@@ -169,7 +169,7 @@ export interface DocumentSchema {
169
169
  page?: PageConfig
170
170
  variables?: VariableDecl[]
171
171
  body?: ElementNode[]
172
- data?: Record<string, unknown>
172
+ // 配置与数据分离:schema **不含 data**(示例/mock 数据是运行态 state.data,渲染时单独传)
173
173
  html?: Record<string, unknown>
174
174
  pdf?: Record<string, unknown>
175
175
  meta?: Record<string, unknown>
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  # CLI: pdfwright-web --port 8765
3
3
  name = "pdfwright-web"
4
- version = "0.2.1"
4
+ version = "0.2.2"
5
5
  description = "pdfwright 的无状态 Web 服务:可视化配置编辑器 + HTML/PDF 预览 + 对外渲染 API(不存储任何数据与内容)"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -6,4 +6,4 @@
6
6
  from pdfwright_web.app import app, create_app, main
7
7
 
8
8
  __all__ = ["app", "create_app", "main"]
9
- __version__ = "0.2.1"
9
+ __version__ = "0.2.2"
@@ -100,7 +100,7 @@ def create_app() -> FastAPI:
100
100
  """构建 FastAPI 应用。"""
101
101
  app = FastAPI(
102
102
  title="pdfwright-web",
103
- version="0.2.1",
103
+ version="0.2.2",
104
104
  description="配置驱动的 PDF 渲染服务(无状态):schema + data → PDF / HTML / 校验",
105
105
  )
106
106
  app.add_middleware(
@@ -86,6 +86,7 @@ async def convert_docx(file: UploadFile = File(...)) -> ConvertResponse:
86
86
  return ConvertResponse(
87
87
  document=with_service_security(result.document).to_dict(),
88
88
  fields=[{"name": f.name, "label": f.label, "kind": f.kind} for f in result.fields],
89
+ data=result.data,
89
90
  )
90
91
 
91
92
 
@@ -124,6 +125,7 @@ async def convert_html(payload: HtmlConvertRequest) -> ConvertResponse:
124
125
  return ConvertResponse(
125
126
  document=result.document.to_dict(),
126
127
  fields=[{"name": f.name, "label": f.label, "kind": f.kind} for f in result.fields],
128
+ data=result.data,
127
129
  )
128
130
 
129
131
 
@@ -139,4 +141,5 @@ async def convert_html_file(file: UploadFile = File(...)) -> ConvertResponse:
139
141
  return ConvertResponse(
140
142
  document=with_service_security(result.document).to_dict(),
141
143
  fields=[{"name": f.name, "label": f.label, "kind": f.kind} for f in result.fields],
144
+ data=result.data,
142
145
  )
@@ -76,6 +76,10 @@ def _load_examples(directory: str) -> dict[str, Any]:
76
76
  except (OSError, json.JSONDecodeError):
77
77
  continue
78
78
  meta = schema.get("meta") or {}
79
+ # 示例文件为了「单文件即完整演示」把 data 写在同一个 JSON 里;
80
+ # 但配置与数据分离 —— 对外只把 schema(不含 data)与 data 分开下发。
81
+ data = schema.get("data") if isinstance(schema.get("data"), dict) else None
82
+ schema = {k: v for k, v in schema.items() if k != "data"}
79
83
  items.append(
80
84
  {
81
85
  "id": path.stem,
@@ -84,6 +88,7 @@ def _load_examples(directory: str) -> dict[str, Any]:
84
88
  "variables": len(schema.get("variables") or []),
85
89
  "blocks": len(schema.get("body") or []),
86
90
  "schema": schema,
91
+ "data": data,
87
92
  }
88
93
  )
89
94
  return {"examples": items, "count": len(items)}
@@ -4,16 +4,24 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
- from pydantic import BaseModel, Field
7
+ from pydantic import BaseModel, Field, field_validator
8
8
 
9
9
 
10
10
  class RenderRequest(BaseModel):
11
- """渲染请求:schema + 填写数据。"""
11
+ """渲染请求:schema + 填写数据(**配置与数据分离**)。"""
12
12
 
13
13
  document: dict[str, Any] = Field(..., description="页面 JSON 配置(schema)")
14
- data: dict[str, Any] | None = Field(default=None, description="表单填写数据(独立于 schema)")
14
+ data: dict[str, Any] = Field(
15
+ default_factory=dict, description="表单填写数据(独立于 schema;缺省为空对象)"
16
+ )
15
17
  filename: str | None = Field(default=None, description="下载文件名(PDF)")
16
18
 
19
+ @field_validator("data", mode="before")
20
+ @classmethod
21
+ def _none_to_empty(cls, value: Any) -> Any:
22
+ """未传 / 传 null 一律视为空对象(缺失数据等价于空数据)。"""
23
+ return value or {}
24
+
17
25
 
18
26
  class ValidateRequest(BaseModel):
19
27
  """校验请求。"""
@@ -50,9 +58,11 @@ class OptionsFetchResponse(BaseModel):
50
58
  class ConvertResponse(BaseModel):
51
59
  """文档转换响应:返回可直接用于编辑器的 schema。
52
60
 
53
- ``warnings`` = 源文件里有、但本次转换没能搬过来的特性(不静默丢弃)。
61
+ ``data`` 是与配置分离的**示例数据**(不是 schema 字段);``warnings`` = 源文件里有、
62
+ 但本次转换没能搬过来的特性(不静默丢弃)。
54
63
  """
55
64
 
56
65
  document: dict[str, Any]
57
66
  fields: list[dict[str, Any]] = []
58
67
  warnings: list[str] = []
68
+ data: dict[str, Any] | None = None