@pyreon/document 0.10.0 → 0.11.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/lib/analysis/index.js.html +1 -1
- package/lib/confluence-Bd3ua1Ut.js.map +1 -1
- package/lib/csv-COrS4qdy.js.map +1 -1
- package/lib/discord-BLUnkEh9.js.map +1 -1
- package/lib/{dist-BsqdI2nY.js → dist-CYL41kqQ.js} +2 -2
- package/lib/dist-CYL41kqQ.js.map +1 -0
- package/lib/{docx-BEBOihjl.js → docx-uNAel545.js} +7 -2
- package/lib/docx-uNAel545.js.map +1 -0
- package/lib/email-D0bbfWq4.js.map +1 -1
- package/lib/{exceljs-BoIDUUaw.js → exceljs-BYETsesT.js} +314 -314
- package/lib/exceljs-BYETsesT.js.map +1 -0
- package/lib/google-chat-CkKCBUWC.js.map +1 -1
- package/lib/html-B5biprN2.js.map +1 -1
- package/lib/index.js +17 -8
- package/lib/index.js.map +1 -1
- package/lib/markdown-CdtlFGC0.js.map +1 -1
- package/lib/notion-iG2C5bEY.js.map +1 -1
- package/lib/{pdf-DIUQUEdj.js → pdf-IuBgTb3T.js} +9 -3
- package/lib/pdf-IuBgTb3T.js.map +1 -0
- package/lib/{pdfmake-DnmLxK4Q.js → pdfmake-CKMX5URW.js} +2 -4
- package/lib/pdfmake-CKMX5URW.js.map +1 -0
- package/lib/{pptx-Dd33oL3_.js → pptx-DXiMiYFM.js} +7 -2
- package/lib/pptx-DXiMiYFM.js.map +1 -0
- package/lib/{pptxgen.es-COcgXsyx.js → pptxgen.es-FsqHs8mD.js} +3 -6
- package/lib/pptxgen.es-FsqHs8mD.js.map +1 -0
- package/lib/sanitize-O_3j1mNJ.js.map +1 -1
- package/lib/slack-BI3EQwYm.js.map +1 -1
- package/lib/svg-BKxumy-p.js.map +1 -1
- package/lib/teams-Cwz9lce0.js.map +1 -1
- package/lib/telegram-gYFqyMXb.js.map +1 -1
- package/lib/text-l1XNXBOC.js.map +1 -1
- package/lib/types/index.d.ts +43 -39
- package/lib/types/index.d.ts.map +1 -1
- package/lib/{vfs_fonts-Df1kkZ4Y.js → vfs_fonts-Cap07Jg3.js} +2 -2
- package/lib/vfs_fonts-Cap07Jg3.js.map +1 -0
- package/lib/whatsapp-CjSGoOKx.js.map +1 -1
- package/lib/{xlsx-Bb5TWyXQ.js → xlsx-Cvu4LBNy.js} +8 -2
- package/lib/xlsx-Cvu4LBNy.js.map +1 -0
- package/package.json +19 -7
- package/src/builder.ts +53 -44
- package/src/download.ts +32 -36
- package/src/env.d.ts +3 -17
- package/src/index.ts +6 -8
- package/src/nodes.ts +45 -45
- package/src/render.ts +45 -118
- package/src/renderers/confluence.ts +64 -80
- package/src/renderers/csv.ts +11 -18
- package/src/renderers/discord.ts +38 -50
- package/src/renderers/docx.ts +78 -120
- package/src/renderers/email.ts +73 -92
- package/src/renderers/google-chat.ts +35 -47
- package/src/renderers/html.ts +78 -101
- package/src/renderers/markdown.ts +43 -53
- package/src/renderers/notion.ts +63 -85
- package/src/renderers/pdf.ts +92 -115
- package/src/renderers/pptx.ts +60 -66
- package/src/renderers/slack.ts +49 -61
- package/src/renderers/svg.ts +49 -63
- package/src/renderers/teams.ts +68 -80
- package/src/renderers/telegram.ts +40 -54
- package/src/renderers/text.ts +44 -66
- package/src/renderers/whatsapp.ts +34 -48
- package/src/renderers/xlsx.ts +47 -61
- package/src/sanitize.ts +21 -25
- package/src/tests/document.test.ts +1337 -1385
- package/src/tests/stress.test.ts +111 -111
- package/src/types.ts +66 -65
- package/lib/dist-BsqdI2nY.js.map +0 -1
- package/lib/docx-BEBOihjl.js.map +0 -1
- package/lib/exceljs-BoIDUUaw.js.map +0 -1
- package/lib/pdf-DIUQUEdj.js.map +0 -1
- package/lib/pdfmake-DnmLxK4Q.js.map +0 -1
- package/lib/pptx-Dd33oL3_.js.map +0 -1
- package/lib/pptxgen.es-COcgXsyx.js.map +0 -1
- package/lib/vfs_fonts-Df1kkZ4Y.js.map +0 -1
- package/lib/xlsx-Bb5TWyXQ.js.map +0 -1
package/src/renderers/xlsx.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
DocChild,
|
|
3
|
-
DocNode,
|
|
4
|
-
DocumentRenderer,
|
|
5
|
-
RenderOptions,
|
|
6
|
-
TableColumn,
|
|
7
|
-
} from '../types'
|
|
1
|
+
import type { DocChild, DocNode, DocumentRenderer, RenderOptions, TableColumn } from "../types"
|
|
8
2
|
|
|
9
3
|
/**
|
|
10
4
|
* XLSX renderer — lazy-loads ExcelJS on first use.
|
|
@@ -13,15 +7,13 @@ import type {
|
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
9
|
function resolveColumn(col: string | TableColumn): TableColumn {
|
|
16
|
-
return typeof col ===
|
|
10
|
+
return typeof col === "string" ? { header: col } : col
|
|
17
11
|
}
|
|
18
12
|
|
|
19
13
|
function getTextContent(children: DocChild[]): string {
|
|
20
14
|
return children
|
|
21
|
-
.map((c) =>
|
|
22
|
-
|
|
23
|
-
)
|
|
24
|
-
.join('')
|
|
15
|
+
.map((c) => (typeof c === "string" ? c : getTextContent((c as DocNode).children)))
|
|
16
|
+
.join("")
|
|
25
17
|
}
|
|
26
18
|
|
|
27
19
|
interface ExtractedSheet {
|
|
@@ -34,18 +26,18 @@ interface ExtractedSheet {
|
|
|
34
26
|
function extractSheets(node: DocNode): ExtractedSheet[] {
|
|
35
27
|
const sheets: ExtractedSheet[] = []
|
|
36
28
|
let currentSheet: ExtractedSheet = {
|
|
37
|
-
name:
|
|
29
|
+
name: "Sheet 1",
|
|
38
30
|
headings: [],
|
|
39
31
|
tables: [],
|
|
40
32
|
}
|
|
41
33
|
|
|
42
34
|
function walk(n: DocNode): void {
|
|
43
35
|
switch (n.type) {
|
|
44
|
-
case
|
|
36
|
+
case "document":
|
|
45
37
|
walkChildren(n)
|
|
46
38
|
break
|
|
47
39
|
|
|
48
|
-
case
|
|
40
|
+
case "page":
|
|
49
41
|
pushCurrentSheet()
|
|
50
42
|
currentSheet = {
|
|
51
43
|
name: `Sheet ${sheets.length + 1}`,
|
|
@@ -55,11 +47,11 @@ function extractSheets(node: DocNode): ExtractedSheet[] {
|
|
|
55
47
|
walkChildren(n)
|
|
56
48
|
break
|
|
57
49
|
|
|
58
|
-
case
|
|
50
|
+
case "heading":
|
|
59
51
|
addHeading(n)
|
|
60
52
|
break
|
|
61
53
|
|
|
62
|
-
case
|
|
54
|
+
case "table":
|
|
63
55
|
currentSheet.tables.push(n)
|
|
64
56
|
break
|
|
65
57
|
|
|
@@ -70,7 +62,7 @@ function extractSheets(node: DocNode): ExtractedSheet[] {
|
|
|
70
62
|
|
|
71
63
|
function walkChildren(n: DocNode): void {
|
|
72
64
|
for (const child of n.children) {
|
|
73
|
-
if (typeof child !==
|
|
65
|
+
if (typeof child !== "string") walk(child)
|
|
74
66
|
}
|
|
75
67
|
}
|
|
76
68
|
|
|
@@ -96,8 +88,8 @@ function extractSheets(node: DocNode): ExtractedSheet[] {
|
|
|
96
88
|
|
|
97
89
|
/** Parse a cell value, handling currencies, percentages, and plain numbers. */
|
|
98
90
|
function parseCellValue(value: string | number | undefined): string | number {
|
|
99
|
-
if (value == null) return
|
|
100
|
-
if (typeof value ===
|
|
91
|
+
if (value == null) return ""
|
|
92
|
+
if (typeof value === "number") return value
|
|
101
93
|
|
|
102
94
|
const trimmed = value.trim()
|
|
103
95
|
|
|
@@ -109,11 +101,11 @@ function parseCellValue(value: string | number | undefined): string | number {
|
|
|
109
101
|
// Currency: "$1,234.56", "$1234", "-$500"
|
|
110
102
|
const currencyMatch = trimmed.match(/^-?\$[\d,]+(\.\d+)?$/)
|
|
111
103
|
if (currencyMatch) {
|
|
112
|
-
return Number.parseFloat(trimmed.replace(/[$,]/g,
|
|
104
|
+
return Number.parseFloat(trimmed.replace(/[$,]/g, ""))
|
|
113
105
|
}
|
|
114
106
|
|
|
115
107
|
// Plain number: "1,234.56", "1234", "-500.5"
|
|
116
|
-
const plainNum = Number(trimmed.replace(/,/g,
|
|
108
|
+
const plainNum = Number(trimmed.replace(/,/g, ""))
|
|
117
109
|
if (!Number.isNaN(plainNum) && /^-?[\d,]+(\.\d+)?$/.test(trimmed)) {
|
|
118
110
|
return plainNum
|
|
119
111
|
}
|
|
@@ -122,26 +114,24 @@ function parseCellValue(value: string | number | undefined): string | number {
|
|
|
122
114
|
}
|
|
123
115
|
|
|
124
116
|
/** Get ExcelJS number format string for a value. */
|
|
125
|
-
function getCellFormat(
|
|
126
|
-
originalValue
|
|
127
|
-
): string | undefined {
|
|
128
|
-
if (typeof originalValue !== 'string') return undefined
|
|
117
|
+
function getCellFormat(originalValue: string | number | undefined): string | undefined {
|
|
118
|
+
if (typeof originalValue !== "string") return undefined
|
|
129
119
|
const trimmed = originalValue.trim()
|
|
130
120
|
|
|
131
|
-
if (/^-?\d+(\.\d+)?%$/.test(trimmed)) return
|
|
132
|
-
if (/^-?\$/.test(trimmed)) return
|
|
121
|
+
if (/^-?\d+(\.\d+)?%$/.test(trimmed)) return "0.00%"
|
|
122
|
+
if (/^-?\$/.test(trimmed)) return "$#,##0.00"
|
|
133
123
|
return undefined
|
|
134
124
|
}
|
|
135
125
|
|
|
136
126
|
/** Map alignment string to ExcelJS horizontal alignment. */
|
|
137
|
-
function mapAlignment(align?: string):
|
|
138
|
-
if (align ===
|
|
127
|
+
function mapAlignment(align?: string): "left" | "center" | "right" | undefined {
|
|
128
|
+
if (align === "left" || align === "center" || align === "right") return align
|
|
139
129
|
return undefined
|
|
140
130
|
}
|
|
141
131
|
|
|
142
132
|
/** Thin border style for ExcelJS. */
|
|
143
|
-
function thinBorder(): { style:
|
|
144
|
-
return { style:
|
|
133
|
+
function thinBorder(): { style: "thin"; color: { argb: string } } {
|
|
134
|
+
return { style: "thin", color: { argb: "FFDDDDDD" } }
|
|
145
135
|
}
|
|
146
136
|
|
|
147
137
|
/** Apply header styling to a cell. */
|
|
@@ -160,16 +150,16 @@ function styleHeaderCell(
|
|
|
160
150
|
cell.value = col.header
|
|
161
151
|
cell.font = {
|
|
162
152
|
bold: true,
|
|
163
|
-
color: { argb: hs?.color?.replace(
|
|
153
|
+
color: { argb: hs?.color?.replace("#", "FF") ?? "FF000000" },
|
|
164
154
|
}
|
|
165
155
|
if (hs?.background) {
|
|
166
156
|
cell.fill = {
|
|
167
|
-
type:
|
|
168
|
-
pattern:
|
|
169
|
-
fgColor: { argb: hs.background.replace(
|
|
157
|
+
type: "pattern",
|
|
158
|
+
pattern: "solid",
|
|
159
|
+
fgColor: { argb: hs.background.replace("#", "FF") },
|
|
170
160
|
}
|
|
171
161
|
}
|
|
172
|
-
cell.alignment = { horizontal: mapAlignment(col.align) ??
|
|
162
|
+
cell.alignment = { horizontal: mapAlignment(col.align) ?? "left" }
|
|
173
163
|
if (bordered) {
|
|
174
164
|
cell.border = {
|
|
175
165
|
top: thinBorder(),
|
|
@@ -198,12 +188,12 @@ function styleDataCell(
|
|
|
198
188
|
cell.value = parseCellValue(rawValue)
|
|
199
189
|
const fmt = getCellFormat(rawValue)
|
|
200
190
|
if (fmt) cell.numFmt = fmt
|
|
201
|
-
cell.alignment = { horizontal: mapAlignment(col.align) ??
|
|
191
|
+
cell.alignment = { horizontal: mapAlignment(col.align) ?? "left" }
|
|
202
192
|
if (striped && isOddRow) {
|
|
203
193
|
cell.fill = {
|
|
204
|
-
type:
|
|
205
|
-
pattern:
|
|
206
|
-
fgColor: { argb:
|
|
194
|
+
type: "pattern",
|
|
195
|
+
pattern: "solid",
|
|
196
|
+
fgColor: { argb: "FFF9F9F9" },
|
|
207
197
|
}
|
|
208
198
|
}
|
|
209
199
|
if (bordered) {
|
|
@@ -226,13 +216,9 @@ function renderTable(
|
|
|
226
216
|
startRow: number,
|
|
227
217
|
): number {
|
|
228
218
|
let rowNum = startRow
|
|
229
|
-
const columns = (
|
|
230
|
-
(tableNode.props.columns ?? []) as (string | TableColumn)[]
|
|
231
|
-
).map(resolveColumn)
|
|
219
|
+
const columns = ((tableNode.props.columns ?? []) as (string | TableColumn)[]).map(resolveColumn)
|
|
232
220
|
const rows = (tableNode.props.rows ?? []) as (string | number)[][]
|
|
233
|
-
const hs = tableNode.props.headerStyle as
|
|
234
|
-
| { background?: string; color?: string }
|
|
235
|
-
| undefined
|
|
221
|
+
const hs = tableNode.props.headerStyle as { background?: string; color?: string } | undefined
|
|
236
222
|
const bordered = (tableNode.props.bordered as boolean) ?? false
|
|
237
223
|
|
|
238
224
|
// Caption
|
|
@@ -278,16 +264,13 @@ function renderTable(
|
|
|
278
264
|
function autoFitColumns(ws: {
|
|
279
265
|
columns: {
|
|
280
266
|
width: number
|
|
281
|
-
eachCell?: (
|
|
282
|
-
opts: { includeEmpty: boolean },
|
|
283
|
-
cb: (cell: { value: unknown }) => void,
|
|
284
|
-
) => void
|
|
267
|
+
eachCell?: (opts: { includeEmpty: boolean }, cb: (cell: { value: unknown }) => void) => void
|
|
285
268
|
}[]
|
|
286
269
|
}): void {
|
|
287
270
|
for (const col of ws.columns) {
|
|
288
271
|
let maxLen = 10
|
|
289
272
|
col.eachCell?.({ includeEmpty: false }, (cell) => {
|
|
290
|
-
const len = String(cell.value ??
|
|
273
|
+
const len = String(cell.value ?? "").length
|
|
291
274
|
if (len > maxLen) maxLen = len
|
|
292
275
|
})
|
|
293
276
|
col.width = Math.min(maxLen + 2, 50)
|
|
@@ -296,16 +279,23 @@ function autoFitColumns(ws: {
|
|
|
296
279
|
|
|
297
280
|
export const xlsxRenderer: DocumentRenderer = {
|
|
298
281
|
async render(node: DocNode, _options?: RenderOptions): Promise<Uint8Array> {
|
|
299
|
-
|
|
282
|
+
let ExcelJS: any
|
|
283
|
+
try {
|
|
284
|
+
ExcelJS = await import("exceljs")
|
|
285
|
+
} catch {
|
|
286
|
+
throw new Error(
|
|
287
|
+
'[@pyreon/document] XLSX renderer requires "exceljs" package. Install it: bun add exceljs',
|
|
288
|
+
)
|
|
289
|
+
}
|
|
300
290
|
const workbook = new ExcelJS.default.Workbook()
|
|
301
291
|
|
|
302
|
-
workbook.creator = (node.props.author as string) ??
|
|
303
|
-
workbook.title = (node.props.title as string) ??
|
|
292
|
+
workbook.creator = (node.props.author as string) ?? ""
|
|
293
|
+
workbook.title = (node.props.title as string) ?? ""
|
|
304
294
|
|
|
305
295
|
const sheets = extractSheets(node)
|
|
306
296
|
|
|
307
297
|
if (sheets.length === 0) {
|
|
308
|
-
workbook.addWorksheet(
|
|
298
|
+
workbook.addWorksheet("Sheet 1")
|
|
309
299
|
}
|
|
310
300
|
|
|
311
301
|
for (const sheet of sheets) {
|
|
@@ -325,11 +315,7 @@ export const xlsxRenderer: DocumentRenderer = {
|
|
|
325
315
|
|
|
326
316
|
// Add tables
|
|
327
317
|
for (const tableNode of sheet.tables) {
|
|
328
|
-
rowNum = renderTable(
|
|
329
|
-
ws as unknown as Parameters<typeof renderTable>[0],
|
|
330
|
-
tableNode,
|
|
331
|
-
rowNum,
|
|
332
|
-
)
|
|
318
|
+
rowNum = renderTable(ws as unknown as Parameters<typeof renderTable>[0], tableNode, rowNum)
|
|
333
319
|
}
|
|
334
320
|
|
|
335
321
|
// Auto-fit columns (approximate)
|
package/src/sanitize.ts
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
* Blocks: semicolons, braces, angle brackets, quotes, backslashes, expressions.
|
|
9
9
|
*/
|
|
10
10
|
export function sanitizeCss(value: string | undefined): string {
|
|
11
|
-
if (value == null) return
|
|
11
|
+
if (value == null) return ""
|
|
12
12
|
// Remove anything that could break out of a CSS value
|
|
13
13
|
return value
|
|
14
|
-
.replace(/[;{}()<>\\'"]/g,
|
|
15
|
-
.replace(/expression\s*\(/gi,
|
|
16
|
-
.replace(/url\s*\(/gi,
|
|
17
|
-
.replace(/javascript\s*:/gi,
|
|
14
|
+
.replace(/[;{}()<>\\'"]/g, "")
|
|
15
|
+
.replace(/expression\s*\(/gi, "")
|
|
16
|
+
.replace(/url\s*\(/gi, "")
|
|
17
|
+
.replace(/javascript\s*:/gi, "")
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -22,7 +22,7 @@ export function sanitizeCss(value: string | undefined): string {
|
|
|
22
22
|
* Returns the value if valid, empty string if not.
|
|
23
23
|
*/
|
|
24
24
|
export function sanitizeColor(value: string | undefined): string {
|
|
25
|
-
if (value == null) return
|
|
25
|
+
if (value == null) return ""
|
|
26
26
|
const trimmed = value.trim()
|
|
27
27
|
// Hex: #fff, #ffffff, #ffffffff
|
|
28
28
|
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed
|
|
@@ -31,21 +31,17 @@ export function sanitizeColor(value: string | undefined): string {
|
|
|
31
31
|
// rgb/rgba/hsl/hsla
|
|
32
32
|
if (/^(rgb|hsl)a?\(\s*[\d.,\s%]+\)$/.test(trimmed)) return trimmed
|
|
33
33
|
// transparent, inherit, currentColor
|
|
34
|
-
if (/^(transparent|inherit|currentColor|initial|unset)$/i.test(trimmed))
|
|
35
|
-
|
|
36
|
-
return ''
|
|
34
|
+
if (/^(transparent|inherit|currentColor|initial|unset)$/i.test(trimmed)) return trimmed
|
|
35
|
+
return ""
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
39
|
* Sanitize a color for XML attributes (DOCX/PPTX) — only hex without #.
|
|
41
40
|
* Returns 6-char hex string or default.
|
|
42
41
|
*/
|
|
43
|
-
export function sanitizeXmlColor(
|
|
44
|
-
value: string | undefined,
|
|
45
|
-
fallback = '000000',
|
|
46
|
-
): string {
|
|
42
|
+
export function sanitizeXmlColor(value: string | undefined, fallback = "000000"): string {
|
|
47
43
|
if (value == null) return fallback
|
|
48
|
-
const hex = value.replace(
|
|
44
|
+
const hex = value.replace("#", "")
|
|
49
45
|
if (/^[0-9a-fA-F]{3,8}$/.test(hex)) return hex
|
|
50
46
|
return fallback
|
|
51
47
|
}
|
|
@@ -55,13 +51,13 @@ export function sanitizeXmlColor(
|
|
|
55
51
|
* Returns the URL if safe, empty string if not.
|
|
56
52
|
*/
|
|
57
53
|
export function sanitizeHref(url: string | undefined): string {
|
|
58
|
-
if (url == null) return
|
|
54
|
+
if (url == null) return ""
|
|
59
55
|
const trimmed = url.trim()
|
|
60
56
|
// Block dangerous protocols
|
|
61
|
-
const lower = trimmed.toLowerCase().replace(/\s/g,
|
|
62
|
-
if (lower.startsWith(
|
|
63
|
-
if (lower.startsWith(
|
|
64
|
-
if (lower.startsWith(
|
|
57
|
+
const lower = trimmed.toLowerCase().replace(/\s/g, "")
|
|
58
|
+
if (lower.startsWith("javascript:")) return ""
|
|
59
|
+
if (lower.startsWith("vbscript:")) return ""
|
|
60
|
+
if (lower.startsWith("data:") && !lower.startsWith("data:image/")) return ""
|
|
65
61
|
return trimmed
|
|
66
62
|
}
|
|
67
63
|
|
|
@@ -70,12 +66,12 @@ export function sanitizeHref(url: string | undefined): string {
|
|
|
70
66
|
* Blocks javascript:, vbscript:, and non-image data: URIs.
|
|
71
67
|
*/
|
|
72
68
|
export function sanitizeImageSrc(src: string | undefined): string {
|
|
73
|
-
if (src == null) return
|
|
69
|
+
if (src == null) return ""
|
|
74
70
|
const trimmed = src.trim()
|
|
75
|
-
const lower = trimmed.toLowerCase().replace(/\s/g,
|
|
76
|
-
if (lower.startsWith(
|
|
77
|
-
if (lower.startsWith(
|
|
78
|
-
if (lower.startsWith(
|
|
71
|
+
const lower = trimmed.toLowerCase().replace(/\s/g, "")
|
|
72
|
+
if (lower.startsWith("javascript:")) return ""
|
|
73
|
+
if (lower.startsWith("vbscript:")) return ""
|
|
74
|
+
if (lower.startsWith("data:") && !lower.startsWith("data:image/")) return ""
|
|
79
75
|
return trimmed
|
|
80
76
|
}
|
|
81
77
|
|
|
@@ -83,6 +79,6 @@ export function sanitizeImageSrc(src: string | undefined): string {
|
|
|
83
79
|
* Sanitize a style attribute value — validates it's safe CSS.
|
|
84
80
|
*/
|
|
85
81
|
export function sanitizeStyle(value: string | undefined): string {
|
|
86
|
-
if (value == null) return
|
|
82
|
+
if (value == null) return ""
|
|
87
83
|
return sanitizeCss(value)
|
|
88
84
|
}
|