@pyreon/document 0.8.0 → 0.10.0
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.
- package/README.md +68 -0
- package/package.json +1 -1
- package/src/tests/stress.test.ts +350 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @pyreon/document
|
|
2
|
+
|
|
3
|
+
Universal document rendering for Pyreon. One template, every output format: HTML, PDF, DOCX, email, XLSX, Markdown, plain text, CSV, and custom formats.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @pyreon/document
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { Document, Page, Heading, Text, Table, render } from '@pyreon/document'
|
|
15
|
+
|
|
16
|
+
const doc = (
|
|
17
|
+
<Document title="Report">
|
|
18
|
+
<Page>
|
|
19
|
+
<Heading>Sales Report</Heading>
|
|
20
|
+
<Text>Q4 performance summary.</Text>
|
|
21
|
+
<Table
|
|
22
|
+
columns={['Region', 'Revenue']}
|
|
23
|
+
rows={[['US', '$1M'], ['EU', '$800K']]}
|
|
24
|
+
/>
|
|
25
|
+
</Page>
|
|
26
|
+
</Document>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
await render(doc, 'pdf') // PDF Uint8Array
|
|
30
|
+
await render(doc, 'email') // email-safe HTML string
|
|
31
|
+
await render(doc, 'md') // Markdown string
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Browser Download
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { download } from '@pyreon/document'
|
|
38
|
+
|
|
39
|
+
await download(doc, 'pdf', 'report.pdf')
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
### Primitives
|
|
45
|
+
|
|
46
|
+
Layout: `Document`, `Page`, `PageBreak`, `Section`, `Row`, `Column`, `Spacer`, `Divider`
|
|
47
|
+
|
|
48
|
+
Content: `Heading`, `Text`, `Image`, `Link`, `Button`, `Code`, `Quote`, `List`, `ListItem`, `Table`
|
|
49
|
+
|
|
50
|
+
### `render(doc, format, options?)`
|
|
51
|
+
|
|
52
|
+
Render a document tree to the specified format. Returns a `RenderResult` (string or `Uint8Array` depending on format).
|
|
53
|
+
|
|
54
|
+
### `download(doc, format, filename, options?)`
|
|
55
|
+
|
|
56
|
+
Browser-only. Renders and triggers a file download.
|
|
57
|
+
|
|
58
|
+
### `registerRenderer(format, renderer)` / `unregisterRenderer(format)`
|
|
59
|
+
|
|
60
|
+
Register custom output formats.
|
|
61
|
+
|
|
62
|
+
### `createDocument()`
|
|
63
|
+
|
|
64
|
+
Imperative builder API for constructing documents without JSX.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
package/package.json
CHANGED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
Document,
|
|
4
|
+
Heading,
|
|
5
|
+
List,
|
|
6
|
+
ListItem,
|
|
7
|
+
Page,
|
|
8
|
+
PageBreak,
|
|
9
|
+
Quote,
|
|
10
|
+
Section,
|
|
11
|
+
Spacer,
|
|
12
|
+
Table,
|
|
13
|
+
Text,
|
|
14
|
+
render,
|
|
15
|
+
} from '../index'
|
|
16
|
+
import type { DocNode } from '../types'
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function generateRows(count: number, cols: number): string[][] {
|
|
21
|
+
return Array.from({ length: count }, (_row, i) =>
|
|
22
|
+
Array.from({ length: cols }, (_col, j) => `Row ${i + 1} Col ${j + 1}`),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function generateLargeDocument(pages: number, rowsPerTable: number): DocNode {
|
|
27
|
+
const pageNodes: DocNode[] = []
|
|
28
|
+
for (let p = 0; p < pages; p++) {
|
|
29
|
+
pageNodes.push(
|
|
30
|
+
Page({
|
|
31
|
+
children: [
|
|
32
|
+
Heading({ level: 1, children: `Page ${p + 1}` }),
|
|
33
|
+
Text({
|
|
34
|
+
children: `This is page ${p + 1} of the stress test document.`,
|
|
35
|
+
}),
|
|
36
|
+
Table({
|
|
37
|
+
columns: ['ID', 'Name', 'Value', 'Status', 'Notes'],
|
|
38
|
+
rows: generateRows(rowsPerTable, 5),
|
|
39
|
+
striped: true,
|
|
40
|
+
headerStyle: { background: '#1a1a2e', color: '#fff' },
|
|
41
|
+
}),
|
|
42
|
+
Spacer({ height: 20 }),
|
|
43
|
+
List({
|
|
44
|
+
ordered: true,
|
|
45
|
+
children: Array.from({ length: 10 }, (_, i) =>
|
|
46
|
+
ListItem({ children: `Item ${i + 1} on page ${p + 1}` }),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
Quote({ children: `Summary for page ${p + 1}` }),
|
|
50
|
+
...(p < pages - 1 ? [PageBreak()] : []),
|
|
51
|
+
],
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Document({
|
|
57
|
+
title: 'Stress Test Document',
|
|
58
|
+
author: 'Pyreon Test Suite',
|
|
59
|
+
children: pageNodes,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── HTML Stress Tests ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe('HTML stress tests', () => {
|
|
66
|
+
it('renders 50-page document', async () => {
|
|
67
|
+
const doc = generateLargeDocument(50, 20)
|
|
68
|
+
const html = (await render(doc, 'html')) as string
|
|
69
|
+
expect(html.length).toBeGreaterThan(100000)
|
|
70
|
+
expect(html).toContain('Page 1')
|
|
71
|
+
expect(html).toContain('Page 50')
|
|
72
|
+
expect(html).toContain('Row 20 Col 5')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders 1000-row table', async () => {
|
|
76
|
+
const doc = Document({
|
|
77
|
+
children: Table({
|
|
78
|
+
columns: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'],
|
|
79
|
+
rows: generateRows(1000, 8),
|
|
80
|
+
striped: true,
|
|
81
|
+
bordered: true,
|
|
82
|
+
}),
|
|
83
|
+
})
|
|
84
|
+
const html = (await render(doc, 'html')) as string
|
|
85
|
+
expect(html).toContain('Row 1 Col 1')
|
|
86
|
+
expect(html).toContain('Row 1000 Col 8')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('renders deeply nested sections', async () => {
|
|
90
|
+
let node: DocNode = Text({ children: 'Deep content' })
|
|
91
|
+
for (let i = 0; i < 20; i++) {
|
|
92
|
+
node = Section({ children: node })
|
|
93
|
+
}
|
|
94
|
+
const doc = Document({ children: node })
|
|
95
|
+
const html = (await render(doc, 'html')) as string
|
|
96
|
+
expect(html).toContain('Deep content')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ─── Email Stress Tests ─────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('email stress tests', () => {
|
|
103
|
+
it('renders large email with multiple sections', async () => {
|
|
104
|
+
const doc = generateLargeDocument(5, 50)
|
|
105
|
+
const html = (await render(doc, 'email')) as string
|
|
106
|
+
expect(html).toContain('max-width:600px')
|
|
107
|
+
expect(html).toContain('Page 5')
|
|
108
|
+
expect(html).toContain('Row 50 Col 5')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ─── Markdown Stress Tests ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('Markdown stress tests', () => {
|
|
115
|
+
it('renders 1000-row table as pipe table', async () => {
|
|
116
|
+
const doc = Document({
|
|
117
|
+
children: Table({
|
|
118
|
+
columns: ['Name', 'Value'],
|
|
119
|
+
rows: generateRows(1000, 2),
|
|
120
|
+
}),
|
|
121
|
+
})
|
|
122
|
+
const md = (await render(doc, 'md')) as string
|
|
123
|
+
expect(md).toContain('Row 1 Col 1')
|
|
124
|
+
expect(md).toContain('Row 1000 Col 2')
|
|
125
|
+
// Count pipe rows
|
|
126
|
+
const pipeLines = md.split('\n').filter((l) => l.startsWith('|'))
|
|
127
|
+
expect(pipeLines.length).toBeGreaterThanOrEqual(1002) // header + separator + 1000 rows
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ─── CSV Stress Tests ───────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe('CSV stress tests', () => {
|
|
134
|
+
it('renders multiple large tables', async () => {
|
|
135
|
+
const doc = Document({
|
|
136
|
+
children: [
|
|
137
|
+
Table({
|
|
138
|
+
columns: ['A', 'B', 'C'],
|
|
139
|
+
rows: generateRows(500, 3),
|
|
140
|
+
caption: 'Table 1',
|
|
141
|
+
}),
|
|
142
|
+
Table({
|
|
143
|
+
columns: ['X', 'Y', 'Z'],
|
|
144
|
+
rows: generateRows(500, 3),
|
|
145
|
+
caption: 'Table 2',
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
})
|
|
149
|
+
const csv = (await render(doc, 'csv')) as string
|
|
150
|
+
expect(csv).toContain('# Table 1')
|
|
151
|
+
expect(csv).toContain('# Table 2')
|
|
152
|
+
const lines = csv.split('\n').filter((l) => l.trim().length > 0)
|
|
153
|
+
expect(lines.length).toBeGreaterThanOrEqual(1004) // 2 tables × (header + 500 rows) + 2 captions
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ─── Text Stress Tests ──────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('text stress tests', () => {
|
|
160
|
+
it('renders large aligned table', async () => {
|
|
161
|
+
const doc = Document({
|
|
162
|
+
children: Table({
|
|
163
|
+
columns: [
|
|
164
|
+
{ header: 'ID', align: 'right' as const },
|
|
165
|
+
{ header: 'Name', align: 'left' as const },
|
|
166
|
+
{ header: 'Amount', align: 'right' as const },
|
|
167
|
+
],
|
|
168
|
+
rows: generateRows(200, 3),
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
const text = (await render(doc, 'text')) as string
|
|
172
|
+
expect(text).toContain('Row 200 Col 3')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ─── SVG Stress Tests ───────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe('SVG stress tests', () => {
|
|
179
|
+
it('renders document with many elements', async () => {
|
|
180
|
+
const children: DocNode[] = []
|
|
181
|
+
for (let i = 0; i < 100; i++) {
|
|
182
|
+
children.push(Heading({ level: 2, children: `Section ${i + 1}` }))
|
|
183
|
+
children.push(Text({ children: `Content for section ${i + 1}` }))
|
|
184
|
+
}
|
|
185
|
+
const doc = Document({ children })
|
|
186
|
+
const svg = (await render(doc, 'svg')) as string
|
|
187
|
+
expect(svg).toContain('<svg')
|
|
188
|
+
expect(svg).toContain('Section 100')
|
|
189
|
+
// Height should be large
|
|
190
|
+
const match = svg.match(/height="(\d+)"/)
|
|
191
|
+
expect(Number(match?.[1])).toBeGreaterThan(3000)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// ─── Slack Stress Tests ─────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe('Slack stress tests', () => {
|
|
198
|
+
it('renders large document to blocks', async () => {
|
|
199
|
+
const doc = generateLargeDocument(10, 10)
|
|
200
|
+
const json = (await render(doc, 'slack')) as string
|
|
201
|
+
const parsed = JSON.parse(json)
|
|
202
|
+
expect(parsed.blocks.length).toBeGreaterThan(50)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ─── PDF Stress Tests ───────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe('PDF stress tests', () => {
|
|
209
|
+
it('renders 10-page document with large tables', async () => {
|
|
210
|
+
const doc = generateLargeDocument(10, 50)
|
|
211
|
+
const pdf = await render(doc, 'pdf')
|
|
212
|
+
expect(pdf).toBeInstanceOf(Uint8Array)
|
|
213
|
+
expect((pdf as Uint8Array).length).toBeGreaterThan(10000)
|
|
214
|
+
// PDF header
|
|
215
|
+
const header = String.fromCharCode(...(pdf as Uint8Array).slice(0, 5))
|
|
216
|
+
expect(header).toBe('%PDF-')
|
|
217
|
+
}, 30000)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// ─── DOCX Stress Tests ──────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe('DOCX stress tests', () => {
|
|
223
|
+
it('renders document with 500-row table', async () => {
|
|
224
|
+
const doc = Document({
|
|
225
|
+
title: 'Large DOCX',
|
|
226
|
+
children: Page({
|
|
227
|
+
children: [
|
|
228
|
+
Heading({ children: 'Large Table' }),
|
|
229
|
+
Table({
|
|
230
|
+
columns: ['A', 'B', 'C', 'D'],
|
|
231
|
+
rows: generateRows(500, 4),
|
|
232
|
+
striped: true,
|
|
233
|
+
bordered: true,
|
|
234
|
+
}),
|
|
235
|
+
],
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
const docx = await render(doc, 'docx')
|
|
239
|
+
expect(docx).toBeInstanceOf(Uint8Array)
|
|
240
|
+
expect((docx as Uint8Array).length).toBeGreaterThan(5000)
|
|
241
|
+
// DOCX is a ZIP — starts with PK
|
|
242
|
+
const header = String.fromCharCode(...(docx as Uint8Array).slice(0, 2))
|
|
243
|
+
expect(header).toBe('PK')
|
|
244
|
+
}, 30000)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// ─── XLSX Stress Tests ──────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
describe('XLSX stress tests', () => {
|
|
250
|
+
it('renders 1000-row spreadsheet', async () => {
|
|
251
|
+
const doc = Document({
|
|
252
|
+
title: 'Large XLSX',
|
|
253
|
+
children: Table({
|
|
254
|
+
columns: ['ID', 'Name', 'Revenue', 'Growth', 'Region'],
|
|
255
|
+
rows: Array.from({ length: 1000 }, (_, i) => [
|
|
256
|
+
String(i + 1),
|
|
257
|
+
`Company ${i + 1}`,
|
|
258
|
+
`$${(Math.random() * 1000000).toFixed(0)}`,
|
|
259
|
+
`${(Math.random() * 100).toFixed(1)}%`,
|
|
260
|
+
['US', 'EU', 'APAC', 'LATAM'][i % 4]!,
|
|
261
|
+
]),
|
|
262
|
+
striped: true,
|
|
263
|
+
}),
|
|
264
|
+
})
|
|
265
|
+
const xlsx = await render(doc, 'xlsx')
|
|
266
|
+
expect(xlsx).toBeInstanceOf(Uint8Array)
|
|
267
|
+
expect((xlsx as Uint8Array).length).toBeGreaterThan(10000)
|
|
268
|
+
}, 30000)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ─── PPTX Stress Tests ──────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe('PPTX stress tests', () => {
|
|
274
|
+
it('renders 20-slide presentation', async () => {
|
|
275
|
+
const pages: DocNode[] = []
|
|
276
|
+
for (let i = 0; i < 20; i++) {
|
|
277
|
+
pages.push(
|
|
278
|
+
Page({
|
|
279
|
+
children: [
|
|
280
|
+
Heading({ children: `Slide ${i + 1}` }),
|
|
281
|
+
Text({ children: `Content for slide ${i + 1}` }),
|
|
282
|
+
Table({
|
|
283
|
+
columns: ['Metric', 'Value'],
|
|
284
|
+
rows: [
|
|
285
|
+
['Revenue', `$${(i + 1) * 100}K`],
|
|
286
|
+
['Growth', `${(i + 1) * 5}%`],
|
|
287
|
+
],
|
|
288
|
+
}),
|
|
289
|
+
],
|
|
290
|
+
}),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
const doc = Document({ title: 'Large Presentation', children: pages })
|
|
294
|
+
const pptx = await render(doc, 'pptx')
|
|
295
|
+
expect(pptx).toBeInstanceOf(Uint8Array)
|
|
296
|
+
expect((pptx as Uint8Array).length).toBeGreaterThan(5000)
|
|
297
|
+
}, 30000)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// ─── Edge Cases ─────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
describe('edge cases', () => {
|
|
303
|
+
it('handles empty document', async () => {
|
|
304
|
+
const doc = Document({ children: [] as unknown as undefined })
|
|
305
|
+
const html = (await render(doc, 'html')) as string
|
|
306
|
+
expect(html).toContain('<!DOCTYPE html>')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('handles empty table', async () => {
|
|
310
|
+
const doc = Document({
|
|
311
|
+
children: Table({ columns: ['A', 'B'], rows: [] }),
|
|
312
|
+
})
|
|
313
|
+
const html = (await render(doc, 'html')) as string
|
|
314
|
+
expect(html).toContain('<table')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('handles special characters in text', async () => {
|
|
318
|
+
const doc = Document({
|
|
319
|
+
children: [
|
|
320
|
+
Text({ children: 'Hello <world> & "quotes" \'apostrophe\'' }),
|
|
321
|
+
Heading({ children: 'Heading with <html>' }),
|
|
322
|
+
],
|
|
323
|
+
})
|
|
324
|
+
const html = (await render(doc, 'html')) as string
|
|
325
|
+
expect(html).toContain('<world>')
|
|
326
|
+
expect(html).toContain('&')
|
|
327
|
+
expect(html).toContain('"')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('handles unicode text', async () => {
|
|
331
|
+
const doc = Document({
|
|
332
|
+
children: [
|
|
333
|
+
Text({ children: '日本語テスト' }),
|
|
334
|
+
Text({ children: 'العربية' }),
|
|
335
|
+
Text({ children: '🎉🚀✨' }),
|
|
336
|
+
],
|
|
337
|
+
})
|
|
338
|
+
const html = (await render(doc, 'html')) as string
|
|
339
|
+
expect(html).toContain('日本語テスト')
|
|
340
|
+
expect(html).toContain('العربية')
|
|
341
|
+
expect(html).toContain('🎉🚀✨')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('handles very long text', async () => {
|
|
345
|
+
const longText = 'A'.repeat(100000)
|
|
346
|
+
const doc = Document({ children: Text({ children: longText }) })
|
|
347
|
+
const html = (await render(doc, 'html')) as string
|
|
348
|
+
expect(html.length).toBeGreaterThan(100000)
|
|
349
|
+
})
|
|
350
|
+
})
|