@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/document",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Universal document rendering for Pyreon — one template, every output format (HTML, PDF, DOCX, email, XLSX, Markdown, and more)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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('&lt;world&gt;')
326
+ expect(html).toContain('&amp;')
327
+ expect(html).toContain('&quot;')
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
+ })