@pyreon/document 0.0.1
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/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/chunk-ErZ26oRB.js +48 -0
- package/lib/confluence-Va8e7RxQ.js +192 -0
- package/lib/confluence-Va8e7RxQ.js.map +1 -0
- package/lib/csv-2c38ub-Y.js +32 -0
- package/lib/csv-2c38ub-Y.js.map +1 -0
- package/lib/discord-DAoUZqvE.js +134 -0
- package/lib/discord-DAoUZqvE.js.map +1 -0
- package/lib/dist-BsqdI2nY.js +20179 -0
- package/lib/dist-BsqdI2nY.js.map +1 -0
- package/lib/docx-CorFwEH9.js +450 -0
- package/lib/docx-CorFwEH9.js.map +1 -0
- package/lib/email-Bn_Brjdp.js +131 -0
- package/lib/email-Bn_Brjdp.js.map +1 -0
- package/lib/exceljs-BoIDUUaw.js +34377 -0
- package/lib/exceljs-BoIDUUaw.js.map +1 -0
- package/lib/google-chat-B6I017I1.js +125 -0
- package/lib/google-chat-B6I017I1.js.map +1 -0
- package/lib/html-De_iS_f0.js +151 -0
- package/lib/html-De_iS_f0.js.map +1 -0
- package/lib/index.js +619 -0
- package/lib/index.js.map +1 -0
- package/lib/markdown-BYC_3C9i.js +75 -0
- package/lib/markdown-BYC_3C9i.js.map +1 -0
- package/lib/notion-DHaQHO6P.js +187 -0
- package/lib/notion-DHaQHO6P.js.map +1 -0
- package/lib/pdf-CDPc5Itc.js +419 -0
- package/lib/pdf-CDPc5Itc.js.map +1 -0
- package/lib/pdfmake-DnmLxK4Q.js +55511 -0
- package/lib/pdfmake-DnmLxK4Q.js.map +1 -0
- package/lib/pptx-DKQU6bjq.js +252 -0
- package/lib/pptx-DKQU6bjq.js.map +1 -0
- package/lib/pptxgen.es-COcgXsyx.js +5697 -0
- package/lib/pptxgen.es-COcgXsyx.js.map +1 -0
- package/lib/slack-CJRJgkag.js +139 -0
- package/lib/slack-CJRJgkag.js.map +1 -0
- package/lib/svg-BM8biZmL.js +187 -0
- package/lib/svg-BM8biZmL.js.map +1 -0
- package/lib/teams-S99tonRG.js +176 -0
- package/lib/teams-S99tonRG.js.map +1 -0
- package/lib/telegram-CbEO_2PN.js +77 -0
- package/lib/telegram-CbEO_2PN.js.map +1 -0
- package/lib/text-B5U8ucRr.js +75 -0
- package/lib/text-B5U8ucRr.js.map +1 -0
- package/lib/types/index.d.ts +528 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/vfs_fonts-Df1kkZ4Y.js +19 -0
- package/lib/vfs_fonts-Df1kkZ4Y.js.map +1 -0
- package/lib/whatsapp-DJ2D1jGG.js +64 -0
- package/lib/whatsapp-DJ2D1jGG.js.map +1 -0
- package/lib/xlsx-D47x-gZ5.js +199 -0
- package/lib/xlsx-D47x-gZ5.js.map +1 -0
- package/package.json +62 -0
- package/src/builder.ts +266 -0
- package/src/download.ts +76 -0
- package/src/env.d.ts +17 -0
- package/src/index.ts +98 -0
- package/src/nodes.ts +315 -0
- package/src/render.ts +222 -0
- package/src/renderers/confluence.ts +231 -0
- package/src/renderers/csv.ts +67 -0
- package/src/renderers/discord.ts +192 -0
- package/src/renderers/docx.ts +612 -0
- package/src/renderers/email.ts +230 -0
- package/src/renderers/google-chat.ts +211 -0
- package/src/renderers/html.ts +225 -0
- package/src/renderers/markdown.ts +144 -0
- package/src/renderers/notion.ts +264 -0
- package/src/renderers/pdf.ts +427 -0
- package/src/renderers/pptx.ts +353 -0
- package/src/renderers/slack.ts +192 -0
- package/src/renderers/svg.ts +254 -0
- package/src/renderers/teams.ts +234 -0
- package/src/renderers/telegram.ts +137 -0
- package/src/renderers/text.ts +154 -0
- package/src/renderers/whatsapp.ts +121 -0
- package/src/renderers/xlsx.ts +342 -0
- package/src/tests/document.test.ts +2920 -0
- package/src/types.ts +291 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DocChild,
|
|
3
|
+
DocNode,
|
|
4
|
+
DocumentRenderer,
|
|
5
|
+
RenderOptions,
|
|
6
|
+
TableColumn,
|
|
7
|
+
} from '../types'
|
|
8
|
+
|
|
9
|
+
function resolveColumn(col: string | TableColumn): TableColumn {
|
|
10
|
+
return typeof col === 'string' ? { header: col } : col
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderChild(child: DocChild): string {
|
|
14
|
+
if (typeof child === 'string') return child
|
|
15
|
+
return renderNode(child)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderChildren(children: DocChild[]): string {
|
|
19
|
+
return children.map(renderChild).join('')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pad(
|
|
23
|
+
str: string,
|
|
24
|
+
width: number,
|
|
25
|
+
align: 'left' | 'center' | 'right' = 'left',
|
|
26
|
+
): string {
|
|
27
|
+
if (str.length >= width) return str.slice(0, width)
|
|
28
|
+
const diff = width - str.length
|
|
29
|
+
if (align === 'center') {
|
|
30
|
+
const left = Math.floor(diff / 2)
|
|
31
|
+
return ' '.repeat(left) + str + ' '.repeat(diff - left)
|
|
32
|
+
}
|
|
33
|
+
if (align === 'right') return ' '.repeat(diff) + str
|
|
34
|
+
return str + ' '.repeat(diff)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderNode(node: DocNode): string {
|
|
38
|
+
const p = node.props
|
|
39
|
+
|
|
40
|
+
switch (node.type) {
|
|
41
|
+
case 'document':
|
|
42
|
+
return renderChildren(node.children)
|
|
43
|
+
|
|
44
|
+
case 'page':
|
|
45
|
+
return renderChildren(node.children)
|
|
46
|
+
|
|
47
|
+
case 'section':
|
|
48
|
+
case 'row':
|
|
49
|
+
case 'column':
|
|
50
|
+
return renderChildren(node.children)
|
|
51
|
+
|
|
52
|
+
case 'heading': {
|
|
53
|
+
const text = renderChildren(node.children)
|
|
54
|
+
const level = (p.level as number) ?? 1
|
|
55
|
+
if (level === 1)
|
|
56
|
+
return `${text.toUpperCase()}\n${'='.repeat(text.length)}\n\n`
|
|
57
|
+
if (level === 2) return `${text}\n${'-'.repeat(text.length)}\n\n`
|
|
58
|
+
return `${text}\n\n`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
case 'text':
|
|
62
|
+
return `${renderChildren(node.children)}\n\n`
|
|
63
|
+
|
|
64
|
+
case 'link':
|
|
65
|
+
return `${renderChildren(node.children)} (${p.href})`
|
|
66
|
+
|
|
67
|
+
case 'image': {
|
|
68
|
+
const alt = (p.alt as string) ?? 'Image'
|
|
69
|
+
const caption = p.caption ? ` — ${p.caption}` : ''
|
|
70
|
+
return `[${alt}${caption}]\n\n`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case 'table': {
|
|
74
|
+
const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
|
|
75
|
+
resolveColumn,
|
|
76
|
+
)
|
|
77
|
+
const rows = (p.rows ?? []) as (string | number)[][]
|
|
78
|
+
|
|
79
|
+
if (columns.length === 0) return ''
|
|
80
|
+
|
|
81
|
+
// Calculate column widths
|
|
82
|
+
const widths = columns.map((col, i) => {
|
|
83
|
+
const headerLen = col.header.length
|
|
84
|
+
const maxDataLen = rows.reduce(
|
|
85
|
+
(max, row) => Math.max(max, String(row[i] ?? '').length),
|
|
86
|
+
0,
|
|
87
|
+
)
|
|
88
|
+
return Math.max(headerLen, maxDataLen, 3)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Header
|
|
92
|
+
const header = columns
|
|
93
|
+
.map((col, i) => pad(col.header, widths[i] ?? 3, col.align))
|
|
94
|
+
.join(' | ')
|
|
95
|
+
const separator = widths.map((w) => '-'.repeat(w ?? 3)).join('-+-')
|
|
96
|
+
|
|
97
|
+
// Rows
|
|
98
|
+
const body = rows
|
|
99
|
+
.map((row) =>
|
|
100
|
+
columns
|
|
101
|
+
.map((col, i) =>
|
|
102
|
+
pad(String(row[i] ?? ''), widths[i] ?? 3, col.align),
|
|
103
|
+
)
|
|
104
|
+
.join(' | '),
|
|
105
|
+
)
|
|
106
|
+
.join('\n')
|
|
107
|
+
|
|
108
|
+
let result = `${header}\n${separator}\n${body}\n\n`
|
|
109
|
+
if (p.caption) result = `${p.caption}\n\n${result}`
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'list': {
|
|
114
|
+
const ordered = p.ordered as boolean | undefined
|
|
115
|
+
return `${node.children
|
|
116
|
+
.filter((c): c is DocNode => typeof c !== 'string')
|
|
117
|
+
.map((item, i) => {
|
|
118
|
+
const prefix = ordered ? `${i + 1}.` : '*'
|
|
119
|
+
return ` ${prefix} ${renderChildren(item.children)}`
|
|
120
|
+
})
|
|
121
|
+
.join('\n')}\n\n`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'list-item':
|
|
125
|
+
return renderChildren(node.children)
|
|
126
|
+
|
|
127
|
+
case 'code':
|
|
128
|
+
return `${renderChildren(node.children)}\n\n`
|
|
129
|
+
|
|
130
|
+
case 'divider':
|
|
131
|
+
return `${'─'.repeat(40)}\n\n`
|
|
132
|
+
|
|
133
|
+
case 'page-break':
|
|
134
|
+
return `\n${'═'.repeat(40)}\n\n`
|
|
135
|
+
|
|
136
|
+
case 'spacer':
|
|
137
|
+
return '\n'
|
|
138
|
+
|
|
139
|
+
case 'button':
|
|
140
|
+
return `[${renderChildren(node.children)}] → ${p.href}\n\n`
|
|
141
|
+
|
|
142
|
+
case 'quote':
|
|
143
|
+
return ` "${renderChildren(node.children)}"\n\n`
|
|
144
|
+
|
|
145
|
+
default:
|
|
146
|
+
return renderChildren(node.children)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const textRenderer: DocumentRenderer = {
|
|
151
|
+
async render(node: DocNode, _options?: RenderOptions): Promise<string> {
|
|
152
|
+
return `${renderNode(node).trim()}\n`
|
|
153
|
+
},
|
|
154
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DocChild,
|
|
3
|
+
DocNode,
|
|
4
|
+
DocumentRenderer,
|
|
5
|
+
RenderOptions,
|
|
6
|
+
TableColumn,
|
|
7
|
+
} from '../types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* WhatsApp renderer — outputs formatted text using WhatsApp's markup.
|
|
11
|
+
* WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```, > quote
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function resolveColumn(col: string | TableColumn): TableColumn {
|
|
15
|
+
return typeof col === 'string' ? { header: col } : col
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getTextContent(children: DocChild[]): string {
|
|
19
|
+
return children
|
|
20
|
+
.map((c) =>
|
|
21
|
+
typeof c === 'string' ? c : getTextContent((c as DocNode).children),
|
|
22
|
+
)
|
|
23
|
+
.join('')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderNode(node: DocNode): string {
|
|
27
|
+
const p = node.props
|
|
28
|
+
|
|
29
|
+
switch (node.type) {
|
|
30
|
+
case 'document':
|
|
31
|
+
case 'page':
|
|
32
|
+
case 'section':
|
|
33
|
+
case 'row':
|
|
34
|
+
case 'column':
|
|
35
|
+
return node.children
|
|
36
|
+
.map((c) => (typeof c === 'string' ? c : renderNode(c)))
|
|
37
|
+
.join('')
|
|
38
|
+
|
|
39
|
+
case 'heading': {
|
|
40
|
+
const text = getTextContent(node.children)
|
|
41
|
+
return `*${text}*\n\n`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
case 'text': {
|
|
45
|
+
let text = getTextContent(node.children)
|
|
46
|
+
if (p.bold) text = `*${text}*`
|
|
47
|
+
if (p.italic) text = `_${text}_`
|
|
48
|
+
if (p.strikethrough) text = `~${text}~`
|
|
49
|
+
return `${text}\n\n`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case 'link': {
|
|
53
|
+
const href = p.href as string
|
|
54
|
+
const text = getTextContent(node.children)
|
|
55
|
+
return `${text}: ${href}\n\n`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 'image':
|
|
59
|
+
// WhatsApp doesn't support inline images in text
|
|
60
|
+
return ''
|
|
61
|
+
|
|
62
|
+
case 'table': {
|
|
63
|
+
const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(
|
|
64
|
+
resolveColumn,
|
|
65
|
+
)
|
|
66
|
+
const rows = (p.rows ?? []) as (string | number)[][]
|
|
67
|
+
|
|
68
|
+
const header = columns.map((c) => `*${c.header}*`).join(' | ')
|
|
69
|
+
const body = rows
|
|
70
|
+
.map((row) => row.map((c) => String(c ?? '')).join(' | '))
|
|
71
|
+
.join('\n')
|
|
72
|
+
|
|
73
|
+
let result = `${header}\n${body}\n\n`
|
|
74
|
+
if (p.caption) result = `_${p.caption}_\n${result}`
|
|
75
|
+
return result
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case 'list': {
|
|
79
|
+
const ordered = p.ordered as boolean | undefined
|
|
80
|
+
return `${node.children
|
|
81
|
+
.filter((c): c is DocNode => typeof c !== 'string')
|
|
82
|
+
.map((item, i) => {
|
|
83
|
+
const prefix = ordered ? `${i + 1}.` : '•'
|
|
84
|
+
return `${prefix} ${getTextContent(item.children)}`
|
|
85
|
+
})
|
|
86
|
+
.join('\n')}\n\n`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'code': {
|
|
90
|
+
const text = getTextContent(node.children)
|
|
91
|
+
return `\`\`\`${text}\`\`\`\n\n`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case 'divider':
|
|
95
|
+
case 'page-break':
|
|
96
|
+
return '───────────\n\n'
|
|
97
|
+
|
|
98
|
+
case 'spacer':
|
|
99
|
+
return '\n'
|
|
100
|
+
|
|
101
|
+
case 'button': {
|
|
102
|
+
const href = p.href as string
|
|
103
|
+
const text = getTextContent(node.children)
|
|
104
|
+
return `*${text}*: ${href}\n\n`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'quote': {
|
|
108
|
+
const text = getTextContent(node.children)
|
|
109
|
+
return `> ${text}\n\n`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
default:
|
|
113
|
+
return ''
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const whatsappRenderer: DocumentRenderer = {
|
|
118
|
+
async render(node: DocNode, _options?: RenderOptions): Promise<string> {
|
|
119
|
+
return renderNode(node).trim()
|
|
120
|
+
},
|
|
121
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DocChild,
|
|
3
|
+
DocNode,
|
|
4
|
+
DocumentRenderer,
|
|
5
|
+
RenderOptions,
|
|
6
|
+
TableColumn,
|
|
7
|
+
} from '../types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* XLSX renderer — lazy-loads ExcelJS on first use.
|
|
11
|
+
* Extracts tables from the document and renders each as a worksheet.
|
|
12
|
+
* Non-table content (headings, text) becomes header rows.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function resolveColumn(col: string | TableColumn): TableColumn {
|
|
16
|
+
return typeof col === 'string' ? { header: col } : col
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getTextContent(children: DocChild[]): string {
|
|
20
|
+
return children
|
|
21
|
+
.map((c) =>
|
|
22
|
+
typeof c === 'string' ? c : getTextContent((c as DocNode).children),
|
|
23
|
+
)
|
|
24
|
+
.join('')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ExtractedSheet {
|
|
28
|
+
name: string
|
|
29
|
+
headings: string[]
|
|
30
|
+
tables: DocNode[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Walk the tree and group content into sheets (one per page, or one global). */
|
|
34
|
+
function extractSheets(node: DocNode): ExtractedSheet[] {
|
|
35
|
+
const sheets: ExtractedSheet[] = []
|
|
36
|
+
let currentSheet: ExtractedSheet = {
|
|
37
|
+
name: 'Sheet 1',
|
|
38
|
+
headings: [],
|
|
39
|
+
tables: [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function walk(n: DocNode): void {
|
|
43
|
+
switch (n.type) {
|
|
44
|
+
case 'document':
|
|
45
|
+
walkChildren(n)
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
case 'page':
|
|
49
|
+
pushCurrentSheet()
|
|
50
|
+
currentSheet = {
|
|
51
|
+
name: `Sheet ${sheets.length + 1}`,
|
|
52
|
+
headings: [],
|
|
53
|
+
tables: [],
|
|
54
|
+
}
|
|
55
|
+
walkChildren(n)
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
case 'heading':
|
|
59
|
+
addHeading(n)
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
case 'table':
|
|
63
|
+
currentSheet.tables.push(n)
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
default:
|
|
67
|
+
walkChildren(n)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function walkChildren(n: DocNode): void {
|
|
72
|
+
for (const child of n.children) {
|
|
73
|
+
if (typeof child !== 'string') walk(child)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pushCurrentSheet(): void {
|
|
78
|
+
if (currentSheet.tables.length > 0 || currentSheet.headings.length > 0) {
|
|
79
|
+
sheets.push(currentSheet)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addHeading(n: DocNode): void {
|
|
84
|
+
const text = getTextContent(n.children)
|
|
85
|
+
currentSheet.headings.push(text)
|
|
86
|
+
if (currentSheet.headings.length === 1) {
|
|
87
|
+
currentSheet.name = text.slice(0, 31) // Excel sheet name max 31 chars
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
walk(node)
|
|
92
|
+
pushCurrentSheet()
|
|
93
|
+
|
|
94
|
+
return sheets
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parse a cell value, handling currencies, percentages, and plain numbers. */
|
|
98
|
+
function parseCellValue(value: string | number | undefined): string | number {
|
|
99
|
+
if (value == null) return ''
|
|
100
|
+
if (typeof value === 'number') return value
|
|
101
|
+
|
|
102
|
+
const trimmed = value.trim()
|
|
103
|
+
|
|
104
|
+
// Percentage: "45%" or "12.5%"
|
|
105
|
+
if (/^-?\d+(\.\d+)?%$/.test(trimmed)) {
|
|
106
|
+
return Number.parseFloat(trimmed) / 100
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Currency: "$1,234.56", "$1234", "-$500"
|
|
110
|
+
const currencyMatch = trimmed.match(/^-?\$[\d,]+(\.\d+)?$/)
|
|
111
|
+
if (currencyMatch) {
|
|
112
|
+
return Number.parseFloat(trimmed.replace(/[$,]/g, ''))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Plain number: "1,234.56", "1234", "-500.5"
|
|
116
|
+
const plainNum = Number(trimmed.replace(/,/g, ''))
|
|
117
|
+
if (!Number.isNaN(plainNum) && /^-?[\d,]+(\.\d+)?$/.test(trimmed)) {
|
|
118
|
+
return plainNum
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Get ExcelJS number format string for a value. */
|
|
125
|
+
function getCellFormat(
|
|
126
|
+
originalValue: string | number | undefined,
|
|
127
|
+
): string | undefined {
|
|
128
|
+
if (typeof originalValue !== 'string') return undefined
|
|
129
|
+
const trimmed = originalValue.trim()
|
|
130
|
+
|
|
131
|
+
if (/^-?\d+(\.\d+)?%$/.test(trimmed)) return '0.00%'
|
|
132
|
+
if (/^-?\$/.test(trimmed)) return '$#,##0.00'
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Map alignment string to ExcelJS horizontal alignment. */
|
|
137
|
+
function mapAlignment(align?: string): 'left' | 'center' | 'right' | undefined {
|
|
138
|
+
if (align === 'left' || align === 'center' || align === 'right') return align
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Thin border style for ExcelJS. */
|
|
143
|
+
function thinBorder(): { style: 'thin'; color: { argb: string } } {
|
|
144
|
+
return { style: 'thin', color: { argb: 'FFDDDDDD' } }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Apply header styling to a cell. */
|
|
148
|
+
function styleHeaderCell(
|
|
149
|
+
cell: {
|
|
150
|
+
font: unknown
|
|
151
|
+
fill: unknown
|
|
152
|
+
alignment: unknown
|
|
153
|
+
border: unknown
|
|
154
|
+
value: unknown
|
|
155
|
+
},
|
|
156
|
+
col: TableColumn,
|
|
157
|
+
hs: { background?: string; color?: string } | undefined,
|
|
158
|
+
bordered: boolean,
|
|
159
|
+
): void {
|
|
160
|
+
cell.value = col.header
|
|
161
|
+
cell.font = {
|
|
162
|
+
bold: true,
|
|
163
|
+
color: { argb: hs?.color?.replace('#', 'FF') ?? 'FF000000' },
|
|
164
|
+
}
|
|
165
|
+
if (hs?.background) {
|
|
166
|
+
cell.fill = {
|
|
167
|
+
type: 'pattern',
|
|
168
|
+
pattern: 'solid',
|
|
169
|
+
fgColor: { argb: hs.background.replace('#', 'FF') },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
cell.alignment = { horizontal: mapAlignment(col.align) ?? 'left' }
|
|
173
|
+
if (bordered) {
|
|
174
|
+
cell.border = {
|
|
175
|
+
top: thinBorder(),
|
|
176
|
+
bottom: thinBorder(),
|
|
177
|
+
left: thinBorder(),
|
|
178
|
+
right: thinBorder(),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Apply data cell value and styling. */
|
|
184
|
+
function styleDataCell(
|
|
185
|
+
cell: {
|
|
186
|
+
value: unknown
|
|
187
|
+
numFmt: unknown
|
|
188
|
+
alignment: unknown
|
|
189
|
+
fill: unknown
|
|
190
|
+
border: unknown
|
|
191
|
+
},
|
|
192
|
+
rawValue: string | number | undefined,
|
|
193
|
+
col: TableColumn,
|
|
194
|
+
striped: boolean,
|
|
195
|
+
isOddRow: boolean,
|
|
196
|
+
bordered: boolean,
|
|
197
|
+
): void {
|
|
198
|
+
cell.value = parseCellValue(rawValue)
|
|
199
|
+
const fmt = getCellFormat(rawValue)
|
|
200
|
+
if (fmt) cell.numFmt = fmt
|
|
201
|
+
cell.alignment = { horizontal: mapAlignment(col.align) ?? 'left' }
|
|
202
|
+
if (striped && isOddRow) {
|
|
203
|
+
cell.fill = {
|
|
204
|
+
type: 'pattern',
|
|
205
|
+
pattern: 'solid',
|
|
206
|
+
fgColor: { argb: 'FFF9F9F9' },
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (bordered) {
|
|
210
|
+
cell.border = {
|
|
211
|
+
top: thinBorder(),
|
|
212
|
+
bottom: thinBorder(),
|
|
213
|
+
left: thinBorder(),
|
|
214
|
+
right: thinBorder(),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Render a single table node into the worksheet starting at the given row. Returns the next row number. */
|
|
220
|
+
function renderTable(
|
|
221
|
+
ws: {
|
|
222
|
+
getRow: (n: number) => { getCell: (n: number) => Record<string, unknown> }
|
|
223
|
+
columns: unknown[]
|
|
224
|
+
},
|
|
225
|
+
tableNode: DocNode,
|
|
226
|
+
startRow: number,
|
|
227
|
+
): number {
|
|
228
|
+
let rowNum = startRow
|
|
229
|
+
const columns = (
|
|
230
|
+
(tableNode.props.columns ?? []) as (string | TableColumn)[]
|
|
231
|
+
).map(resolveColumn)
|
|
232
|
+
const rows = (tableNode.props.rows ?? []) as (string | number)[][]
|
|
233
|
+
const hs = tableNode.props.headerStyle as
|
|
234
|
+
| { background?: string; color?: string }
|
|
235
|
+
| undefined
|
|
236
|
+
const bordered = (tableNode.props.bordered as boolean) ?? false
|
|
237
|
+
|
|
238
|
+
// Caption
|
|
239
|
+
if (tableNode.props.caption) {
|
|
240
|
+
const captionRow = ws.getRow(rowNum)
|
|
241
|
+
const captionCell = captionRow.getCell(1)
|
|
242
|
+
captionCell.value = tableNode.props.caption as string
|
|
243
|
+
captionCell.font = { italic: true, size: 10 }
|
|
244
|
+
rowNum++
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Header row
|
|
248
|
+
const headerRow = ws.getRow(rowNum)
|
|
249
|
+
for (let i = 0; i < columns.length; i++) {
|
|
250
|
+
const col = columns[i]
|
|
251
|
+
if (!col) continue
|
|
252
|
+
styleHeaderCell(headerRow.getCell(i + 1) as any, col, hs, bordered)
|
|
253
|
+
}
|
|
254
|
+
rowNum++
|
|
255
|
+
|
|
256
|
+
// Data rows
|
|
257
|
+
for (let r = 0; r < rows.length; r++) {
|
|
258
|
+
const dataRow = ws.getRow(rowNum)
|
|
259
|
+
for (let c = 0; c < columns.length; c++) {
|
|
260
|
+
const col = columns[c]
|
|
261
|
+
if (!col) continue
|
|
262
|
+
styleDataCell(
|
|
263
|
+
dataRow.getCell(c + 1) as any,
|
|
264
|
+
rows[r]?.[c],
|
|
265
|
+
col,
|
|
266
|
+
(tableNode.props.striped as boolean) ?? false,
|
|
267
|
+
r % 2 === 1,
|
|
268
|
+
bordered,
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
rowNum++
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return rowNum + 1 // gap after table
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Auto-fit column widths based on content. */
|
|
278
|
+
function autoFitColumns(ws: {
|
|
279
|
+
columns: {
|
|
280
|
+
width: number
|
|
281
|
+
eachCell?: (
|
|
282
|
+
opts: { includeEmpty: boolean },
|
|
283
|
+
cb: (cell: { value: unknown }) => void,
|
|
284
|
+
) => void
|
|
285
|
+
}[]
|
|
286
|
+
}): void {
|
|
287
|
+
for (const col of ws.columns) {
|
|
288
|
+
let maxLen = 10
|
|
289
|
+
col.eachCell?.({ includeEmpty: false }, (cell) => {
|
|
290
|
+
const len = String(cell.value ?? '').length
|
|
291
|
+
if (len > maxLen) maxLen = len
|
|
292
|
+
})
|
|
293
|
+
col.width = Math.min(maxLen + 2, 50)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const xlsxRenderer: DocumentRenderer = {
|
|
298
|
+
async render(node: DocNode, _options?: RenderOptions): Promise<Uint8Array> {
|
|
299
|
+
const ExcelJS = await import('exceljs')
|
|
300
|
+
const workbook = new ExcelJS.default.Workbook()
|
|
301
|
+
|
|
302
|
+
workbook.creator = (node.props.author as string) ?? ''
|
|
303
|
+
workbook.title = (node.props.title as string) ?? ''
|
|
304
|
+
|
|
305
|
+
const sheets = extractSheets(node)
|
|
306
|
+
|
|
307
|
+
if (sheets.length === 0) {
|
|
308
|
+
workbook.addWorksheet('Sheet 1')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const sheet of sheets) {
|
|
312
|
+
const ws = workbook.addWorksheet(sheet.name)
|
|
313
|
+
|
|
314
|
+
let rowNum = 1
|
|
315
|
+
|
|
316
|
+
// Add headings as title rows
|
|
317
|
+
for (const heading of sheet.headings) {
|
|
318
|
+
const row = ws.getRow(rowNum)
|
|
319
|
+
row.getCell(1).value = heading
|
|
320
|
+
row.getCell(1).font = { bold: true, size: 14 }
|
|
321
|
+
rowNum++
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (sheet.headings.length > 0) rowNum++ // gap after headings
|
|
325
|
+
|
|
326
|
+
// Add tables
|
|
327
|
+
for (const tableNode of sheet.tables) {
|
|
328
|
+
rowNum = renderTable(
|
|
329
|
+
ws as unknown as Parameters<typeof renderTable>[0],
|
|
330
|
+
tableNode,
|
|
331
|
+
rowNum,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Auto-fit columns (approximate)
|
|
336
|
+
autoFitColumns(ws as unknown as Parameters<typeof autoFitColumns>[0])
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const buffer = await workbook.xlsx.writeBuffer()
|
|
340
|
+
return new Uint8Array(buffer as ArrayBuffer)
|
|
341
|
+
},
|
|
342
|
+
}
|