@pyreon/document 0.11.4 → 0.11.6

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 (50) hide show
  1. package/README.md +7 -4
  2. package/lib/confluence-Bd3ua1Ut.js.map +1 -1
  3. package/lib/csv-COrS4qdy.js.map +1 -1
  4. package/lib/discord-BLUnkEh9.js.map +1 -1
  5. package/lib/docx-uNAel545.js.map +1 -1
  6. package/lib/email-D0bbfWq4.js.map +1 -1
  7. package/lib/google-chat-CkKCBUWC.js.map +1 -1
  8. package/lib/html-B5biprN2.js.map +1 -1
  9. package/lib/index.js.map +1 -1
  10. package/lib/markdown-CdtlFGC0.js.map +1 -1
  11. package/lib/notion-iG2C5bEY.js.map +1 -1
  12. package/lib/pdf-IuBgTb3T.js.map +1 -1
  13. package/lib/pptx-DXiMiYFM.js.map +1 -1
  14. package/lib/sanitize-O_3j1mNJ.js.map +1 -1
  15. package/lib/slack-BI3EQwYm.js.map +1 -1
  16. package/lib/svg-BKxumy-p.js.map +1 -1
  17. package/lib/teams-Cwz9lce0.js.map +1 -1
  18. package/lib/telegram-gYFqyMXb.js.map +1 -1
  19. package/lib/text-l1XNXBOC.js.map +1 -1
  20. package/lib/types/index.d.ts +27 -27
  21. package/lib/whatsapp-CjSGoOKx.js.map +1 -1
  22. package/lib/xlsx-Cvu4LBNy.js.map +1 -1
  23. package/package.json +21 -21
  24. package/src/builder.ts +36 -36
  25. package/src/download.ts +32 -32
  26. package/src/index.ts +5 -10
  27. package/src/nodes.ts +45 -45
  28. package/src/render.ts +43 -43
  29. package/src/renderers/confluence.ts +63 -63
  30. package/src/renderers/csv.ts +10 -10
  31. package/src/renderers/discord.ts +37 -37
  32. package/src/renderers/docx.ts +57 -57
  33. package/src/renderers/email.ts +72 -72
  34. package/src/renderers/google-chat.ts +34 -34
  35. package/src/renderers/html.ts +76 -76
  36. package/src/renderers/markdown.ts +42 -42
  37. package/src/renderers/notion.ts +60 -60
  38. package/src/renderers/pdf.ts +78 -78
  39. package/src/renderers/pptx.ts +51 -51
  40. package/src/renderers/slack.ts +48 -48
  41. package/src/renderers/svg.ts +47 -47
  42. package/src/renderers/teams.ts +67 -67
  43. package/src/renderers/telegram.ts +39 -39
  44. package/src/renderers/text.ts +43 -43
  45. package/src/renderers/whatsapp.ts +33 -33
  46. package/src/renderers/xlsx.ts +35 -35
  47. package/src/sanitize.ts +20 -20
  48. package/src/tests/document.test.ts +1302 -1302
  49. package/src/tests/stress.test.ts +110 -110
  50. package/src/types.ts +61 -61
@@ -1,4 +1,4 @@
1
- import { sanitizeHref, sanitizeXmlColor } from "../sanitize"
1
+ import { sanitizeHref, sanitizeXmlColor } from '../sanitize'
2
2
  import type {
3
3
  DocChild,
4
4
  DocNode,
@@ -7,20 +7,20 @@ import type {
7
7
  PageSize,
8
8
  RenderOptions,
9
9
  TableColumn,
10
- } from "../types"
10
+ } from '../types'
11
11
 
12
12
  /**
13
13
  * DOCX renderer — lazy-loads the 'docx' npm package on first use.
14
14
  */
15
15
 
16
16
  function resolveColumn(col: string | TableColumn): TableColumn {
17
- return typeof col === "string" ? { header: col } : col
17
+ return typeof col === 'string' ? { header: col } : col
18
18
  }
19
19
 
20
20
  function getTextContent(children: DocChild[]): string {
21
21
  return children
22
- .map((c) => (typeof c === "string" ? c : getTextContent((c as DocNode).children)))
23
- .join("")
22
+ .map((c) => (typeof c === 'string' ? c : getTextContent((c as DocNode).children)))
23
+ .join('')
24
24
  }
25
25
 
26
26
  /** Parse a data URL and return the base64 data and media type, or null for external URLs. */
@@ -46,7 +46,7 @@ function getPageSize(
46
46
  }
47
47
  const dims = sizes[size]
48
48
  if (!dims) return undefined
49
- if (orientation === "landscape") {
49
+ if (orientation === 'landscape') {
50
50
  return { width: dims.height, height: dims.width }
51
51
  }
52
52
  return dims
@@ -57,7 +57,7 @@ function getPageMargins(
57
57
  margin?: number | [number, number] | [number, number, number, number],
58
58
  ): object | undefined {
59
59
  if (margin == null) return undefined
60
- if (typeof margin === "number") {
60
+ if (typeof margin === 'number') {
61
61
  const twips = margin * 20
62
62
  return { top: twips, right: twips, bottom: twips, left: twips }
63
63
  }
@@ -80,15 +80,15 @@ function getPageMargins(
80
80
  /** Map percentage column width to DOCX table column width. */
81
81
  function getColumnWidth(width?: number | string): { size: number; type: unknown } | undefined {
82
82
  if (width == null) return undefined
83
- if (typeof width === "number") return undefined
83
+ if (typeof width === 'number') return undefined
84
84
  const match = width.match(/^(\d+)%$/)
85
85
  if (!match) return undefined
86
- return { size: Number.parseInt(match[1]!, 10) * 100, type: "pct" as unknown }
86
+ return { size: Number.parseInt(match[1]!, 10) * 100, type: 'pct' as unknown }
87
87
  }
88
88
 
89
89
  /** Shared context passed to per-node-type render helpers. */
90
90
  interface DocxCtx {
91
- docx: typeof import("docx")
91
+ docx: typeof import('docx')
92
92
  children: unknown[]
93
93
  alignmentMap: (align?: string) => unknown
94
94
  processListItems: (n: DocNode, listRef: string, level: number, ordered: boolean) => void
@@ -135,7 +135,7 @@ function renderTextNode(ctx: DocxCtx, n: DocNode): void {
135
135
  ...(p.underline ? { underline: {} } : {}),
136
136
  ...(p.strikethrough != null ? { strike: p.strikethrough as boolean } : {}),
137
137
  ...(p.size != null ? { size: (p.size as number) * 2 } : {}),
138
- color: sanitizeXmlColor(p.color as string, "333333"),
138
+ color: sanitizeXmlColor(p.color as string, '333333'),
139
139
  }),
140
140
  ],
141
141
  alignment: alignmentMap(p.align as string) as any,
@@ -155,7 +155,7 @@ function renderLink(ctx: DocxCtx, n: DocNode): void {
155
155
  children: [
156
156
  new docx.TextRun({
157
157
  text: getTextContent(n.children),
158
- color: sanitizeXmlColor(p.color as string, "4f46e5"),
158
+ color: sanitizeXmlColor(p.color as string, '4f46e5'),
159
159
  underline: { type: docx.UnderlineType.SINGLE },
160
160
  }),
161
161
  ],
@@ -178,9 +178,9 @@ function renderImage(ctx: DocxCtx, n: DocNode): void {
178
178
  new docx.Paragraph({
179
179
  children: [
180
180
  new docx.ImageRun({
181
- data: Buffer.from(parsed.data, "base64"),
181
+ data: Buffer.from(parsed.data, 'base64'),
182
182
  transformation: { width: imgWidth, height: imgHeight },
183
- type: parsed.mime === "image/png" ? "png" : "jpg",
183
+ type: parsed.mime === 'image/png' ? 'png' : 'jpg',
184
184
  }),
185
185
  ],
186
186
  alignment: alignmentMap(p.align as string) as any,
@@ -194,7 +194,7 @@ function renderImage(ctx: DocxCtx, n: DocNode): void {
194
194
  text: p.caption as string,
195
195
  italics: true,
196
196
  size: 20,
197
- color: "666666",
197
+ color: '666666',
198
198
  }),
199
199
  ],
200
200
  alignment: alignmentMap(p.align as string) as any,
@@ -203,15 +203,15 @@ function renderImage(ctx: DocxCtx, n: DocNode): void {
203
203
  )
204
204
  }
205
205
  } else {
206
- const alt = (p.alt as string) ?? "Image"
207
- const caption = p.caption ? ` — ${p.caption}` : ""
206
+ const alt = (p.alt as string) ?? 'Image'
207
+ const caption = p.caption ? ` — ${p.caption}` : ''
208
208
  children.push(
209
209
  new docx.Paragraph({
210
210
  children: [
211
211
  new docx.TextRun({
212
212
  text: `[${alt}${caption}]`,
213
213
  italics: true,
214
- color: "999999",
214
+ color: '999999',
215
215
  }),
216
216
  ],
217
217
  }),
@@ -227,7 +227,7 @@ function renderDocxTable(ctx: DocxCtx, n: DocNode): void {
227
227
  const hs = p.headerStyle as { background?: string; color?: string } | undefined
228
228
  const bordered = p.bordered as boolean | undefined
229
229
  const borderStyle = bordered
230
- ? { style: docx.BorderStyle.SINGLE, size: 1, color: "DDDDDD" }
230
+ ? { style: docx.BorderStyle.SINGLE, size: 1, color: 'DDDDDD' }
231
231
  : undefined
232
232
  const cellBorders = borderStyle
233
233
  ? {
@@ -277,12 +277,12 @@ function renderDocxTable(ctx: DocxCtx, n: DocNode): void {
277
277
  new docx.TableCell({
278
278
  children: [
279
279
  new docx.Paragraph({
280
- children: [new docx.TextRun({ text: String(row[colIdx] ?? "") })],
280
+ children: [new docx.TextRun({ text: String(row[colIdx] ?? '') })],
281
281
  alignment: alignmentMap(col.align) as any,
282
282
  }),
283
283
  ],
284
284
  ...(p.striped && rowIdx % 2 === 1
285
- ? { shading: { fill: "F9F9F9", type: docx.ShadingType.SOLID } }
285
+ ? { shading: { fill: 'F9F9F9', type: docx.ShadingType.SOLID } }
286
286
  : {}),
287
287
  ...(cellBorders != null ? { borders: cellBorders } : {}),
288
288
  width: getColumnWidth(col.width as string | undefined) as any,
@@ -312,7 +312,7 @@ function renderDocxTable(ctx: DocxCtx, n: DocNode): void {
312
312
  width: { size: 100, type: docx.WidthType.PERCENTAGE },
313
313
  }),
314
314
  )
315
- children.push(new docx.Paragraph({ text: "", spacing: { after: 120 } }))
315
+ children.push(new docx.Paragraph({ text: '', spacing: { after: 120 } }))
316
316
  }
317
317
 
318
318
  function renderList(ctx: DocxCtx, n: DocNode): void {
@@ -320,14 +320,14 @@ function renderList(ctx: DocxCtx, n: DocNode): void {
320
320
  const ordered = n.props.ordered as boolean | undefined
321
321
  const listRef = nextListId()
322
322
  processListItems(n, listRef, 0, ordered ?? false)
323
- children.push(new docx.Paragraph({ text: "", spacing: { after: 60 } }))
323
+ children.push(new docx.Paragraph({ text: '', spacing: { after: 60 } }))
324
324
  }
325
325
 
326
326
  function renderButtonOrQuote(ctx: DocxCtx, n: DocNode): void {
327
327
  const { docx, children } = ctx
328
328
  const p = n.props
329
329
  const text = getTextContent(n.children)
330
- if (n.type === "button") {
330
+ if (n.type === 'button') {
331
331
  children.push(
332
332
  new docx.Paragraph({
333
333
  children: [
@@ -337,7 +337,7 @@ function renderButtonOrQuote(ctx: DocxCtx, n: DocNode): void {
337
337
  new docx.TextRun({
338
338
  text,
339
339
  bold: true,
340
- color: "4F46E5",
340
+ color: '4F46E5',
341
341
  underline: { type: docx.UnderlineType.SINGLE },
342
342
  }),
343
343
  ],
@@ -349,13 +349,13 @@ function renderButtonOrQuote(ctx: DocxCtx, n: DocNode): void {
349
349
  } else {
350
350
  children.push(
351
351
  new docx.Paragraph({
352
- children: [new docx.TextRun({ text, italics: true, color: "555555" })],
352
+ children: [new docx.TextRun({ text, italics: true, color: '555555' })],
353
353
  indent: { left: 720 },
354
354
  border: {
355
355
  left: {
356
356
  style: docx.BorderStyle.SINGLE,
357
357
  size: 6,
358
- color: sanitizeXmlColor(p.borderColor as string, "DDDDDD"),
358
+ color: sanitizeXmlColor(p.borderColor as string, 'DDDDDD'),
359
359
  },
360
360
  },
361
361
  spacing: { after: 120 },
@@ -366,9 +366,9 @@ function renderButtonOrQuote(ctx: DocxCtx, n: DocNode): void {
366
366
 
367
367
  export const docxRenderer: DocumentRenderer = {
368
368
  async render(node: DocNode, _options?: RenderOptions): Promise<Uint8Array> {
369
- let docx: typeof import("docx")
369
+ let docx: typeof import('docx')
370
370
  try {
371
- docx = await import("docx")
371
+ docx = await import('docx')
372
372
  } catch {
373
373
  throw new Error(
374
374
  '[@pyreon/document] DOCX renderer requires "docx" package. Install it: bun add docx',
@@ -389,13 +389,13 @@ export const docxRenderer: DocumentRenderer = {
389
389
  }
390
390
 
391
391
  function processListItems(n: DocNode, listRef: string, level: number, ordered: boolean): void {
392
- const items = n.children.filter((c): c is DocNode => typeof c !== "string")
392
+ const items = n.children.filter((c): c is DocNode => typeof c !== 'string')
393
393
  for (const item of items) {
394
394
  const nestedList = item.children.find(
395
- (c): c is DocNode => typeof c !== "string" && (c as DocNode).type === "list",
395
+ (c): c is DocNode => typeof c !== 'string' && (c as DocNode).type === 'list',
396
396
  )
397
397
  const textChildren = item.children.filter(
398
- (c) => typeof c === "string" || (c as DocNode).type !== "list",
398
+ (c) => typeof c === 'string' || (c as DocNode).type !== 'list',
399
399
  )
400
400
  children.push(
401
401
  new docx.Paragraph({
@@ -420,73 +420,73 @@ export const docxRenderer: DocumentRenderer = {
420
420
 
421
421
  function processNode(n: DocNode): void {
422
422
  switch (n.type) {
423
- case "document":
424
- case "page":
425
- case "section":
426
- case "row":
427
- case "column":
423
+ case 'document':
424
+ case 'page':
425
+ case 'section':
426
+ case 'row':
427
+ case 'column':
428
428
  for (const child of n.children) {
429
- if (typeof child !== "string") processNode(child)
429
+ if (typeof child !== 'string') processNode(child)
430
430
  else children.push(new docx.Paragraph({ text: child }))
431
431
  }
432
432
  break
433
- case "heading":
433
+ case 'heading':
434
434
  renderHeading(ctx, n)
435
435
  break
436
- case "text":
436
+ case 'text':
437
437
  renderTextNode(ctx, n)
438
438
  break
439
- case "link":
439
+ case 'link':
440
440
  renderLink(ctx, n)
441
441
  break
442
- case "image":
442
+ case 'image':
443
443
  renderImage(ctx, n)
444
444
  break
445
- case "table":
445
+ case 'table':
446
446
  renderDocxTable(ctx, n)
447
447
  break
448
- case "list":
448
+ case 'list':
449
449
  renderList(ctx, n)
450
450
  break
451
- case "code":
451
+ case 'code':
452
452
  children.push(
453
453
  new docx.Paragraph({
454
454
  children: [
455
455
  new docx.TextRun({
456
456
  text: getTextContent(n.children),
457
- font: "Courier New",
457
+ font: 'Courier New',
458
458
  size: 20,
459
459
  }),
460
460
  ],
461
- shading: { fill: "F5F5F5", type: docx.ShadingType.SOLID },
461
+ shading: { fill: 'F5F5F5', type: docx.ShadingType.SOLID },
462
462
  spacing: { after: 120 },
463
463
  }),
464
464
  )
465
465
  break
466
- case "divider":
466
+ case 'divider':
467
467
  children.push(
468
468
  new docx.Paragraph({
469
469
  border: {
470
470
  bottom: {
471
471
  style: docx.BorderStyle.SINGLE,
472
472
  size: (n.props.thickness as number | undefined) ?? 1,
473
- color: sanitizeXmlColor(n.props.color as string, "DDDDDD"),
473
+ color: sanitizeXmlColor(n.props.color as string, 'DDDDDD'),
474
474
  },
475
475
  },
476
476
  spacing: { before: 120, after: 120 },
477
477
  }),
478
478
  )
479
479
  break
480
- case "spacer":
480
+ case 'spacer':
481
481
  children.push(
482
482
  new docx.Paragraph({
483
- text: "",
483
+ text: '',
484
484
  spacing: { after: (n.props.height as number) * 20 },
485
485
  }),
486
486
  )
487
487
  break
488
- case "button":
489
- case "quote":
488
+ case 'button':
489
+ case 'quote':
490
490
  renderButtonOrQuote(ctx, n)
491
491
  break
492
492
  }
@@ -513,11 +513,11 @@ export const docxRenderer: DocumentRenderer = {
513
513
 
514
514
  // Extract page properties from first page node
515
515
  const pageNode =
516
- node.type === "document"
516
+ node.type === 'document'
517
517
  ? (node.children.find(
518
- (c): c is DocNode => typeof c !== "string" && (c as DocNode).type === "page",
518
+ (c): c is DocNode => typeof c !== 'string' && (c as DocNode).type === 'page',
519
519
  ) as DocNode | undefined)
520
- : node.type === "page"
520
+ : node.type === 'page'
521
521
  ? node
522
522
  : undefined
523
523
 
@@ -536,7 +536,7 @@ export const docxRenderer: DocumentRenderer = {
536
536
  if (!text) return undefined
537
537
  return [
538
538
  new docx.Paragraph({
539
- children: [new docx.TextRun({ text, size: 18, color: "999999" })],
539
+ children: [new docx.TextRun({ text, size: 18, color: '999999' })],
540
540
  alignment: docx.AlignmentType.CENTER,
541
541
  }),
542
542
  ]
@@ -1,5 +1,5 @@
1
- import { sanitizeColor, sanitizeHref, sanitizeImageSrc } from "../sanitize"
2
- import type { DocChild, DocNode, DocumentRenderer, RenderOptions, TableColumn } from "../types"
1
+ import { sanitizeColor, sanitizeHref, sanitizeImageSrc } from '../sanitize'
2
+ import type { DocChild, DocNode, DocumentRenderer, RenderOptions, TableColumn } from '../types'
3
3
 
4
4
  /**
5
5
  * Email renderer — generates table-based HTML with inline styles
@@ -15,72 +15,72 @@ import type { DocChild, DocNode, DocumentRenderer, RenderOptions, TableColumn }
15
15
 
16
16
  function esc(str: string): string {
17
17
  return str
18
- .replace(/&/g, "&amp;")
19
- .replace(/</g, "&lt;")
20
- .replace(/>/g, "&gt;")
21
- .replace(/"/g, "&quot;")
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
22
  }
23
23
 
24
24
  function resolveColumn(col: string | TableColumn): TableColumn {
25
- return typeof col === "string" ? { header: col } : col
25
+ return typeof col === 'string' ? { header: col } : col
26
26
  }
27
27
 
28
28
  function renderChild(child: DocChild): string {
29
- if (typeof child === "string") return esc(child)
29
+ if (typeof child === 'string') return esc(child)
30
30
  return renderNode(child)
31
31
  }
32
32
 
33
33
  function renderChildren(children: DocChild[]): string {
34
- return children.map(renderChild).join("")
34
+ return children.map(renderChild).join('')
35
35
  }
36
36
 
37
- function wrapInTable(content: string, style = ""): string {
38
- return `<table width="100%" cellpadding="0" cellspacing="0" border="0"${style ? ` style="${style}"` : ""}><tr><td>${content}</td></tr></table>`
37
+ function wrapInTable(content: string, style = ''): string {
38
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0"${style ? ` style="${style}"` : ''}><tr><td>${content}</td></tr></table>`
39
39
  }
40
40
 
41
41
  function renderNode(node: DocNode): string {
42
42
  const p = node.props
43
43
 
44
44
  switch (node.type) {
45
- case "document": {
46
- const title = p.title ? `<title>${esc(p.title as string)}</title>` : ""
45
+ case 'document': {
46
+ const title = p.title ? `<title>${esc(p.title as string)}</title>` : ''
47
47
  const preview = p.subject
48
48
  ? `<div style="display:none;max-height:0;overflow:hidden">${esc(p.subject as string)}</div>`
49
- : ""
49
+ : ''
50
50
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${title}<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]--></head><body style="margin:0;padding:0;background-color:#f4f4f4;font-family:Arial,Helvetica,sans-serif">${preview}<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f4f4"><tr><td align="center" style="padding:20px 0"><table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff;max-width:600px;width:100%"><tr><td>${renderChildren(node.children)}</td></tr></table></td></tr></table></body></html>`
51
51
  }
52
52
 
53
- case "page":
53
+ case 'page':
54
54
  // In email, pages are just content sections
55
55
  return renderChildren(node.children)
56
56
 
57
- case "section": {
58
- const bg = p.background ? `background-color:${sanitizeColor(p.background as string)};` : ""
57
+ case 'section': {
58
+ const bg = p.background ? `background-color:${sanitizeColor(p.background as string)};` : ''
59
59
  const pad = p.padding
60
- ? `padding:${typeof p.padding === "number" ? `${p.padding}px` : Array.isArray(p.padding) ? (p.padding as number[]).map((v) => `${v}px`).join(" ") : "0"}`
61
- : "padding:0"
62
- const radius = p.borderRadius ? `border-radius:${p.borderRadius}px;` : ""
60
+ ? `padding:${typeof p.padding === 'number' ? `${p.padding}px` : Array.isArray(p.padding) ? (p.padding as number[]).map((v) => `${v}px`).join(' ') : '0'}`
61
+ : 'padding:0'
62
+ const radius = p.borderRadius ? `border-radius:${p.borderRadius}px;` : ''
63
63
 
64
- if (p.direction === "row") {
64
+ if (p.direction === 'row') {
65
65
  // Row layout via nested table
66
- const children = node.children.filter((c): c is DocNode => typeof c !== "string")
66
+ const children = node.children.filter((c): c is DocNode => typeof c !== 'string')
67
67
  const colWidth = Math.floor(100 / Math.max(children.length, 1))
68
- return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="${bg}${radius}${pad}"><tr>${children.map((child) => `<td width="${colWidth}%" valign="top" style="padding:${(p.gap as number | undefined) ? `0 ${(p.gap as number) / 2}px` : "0"}">${renderNode(child)}</td>`).join("")}</tr></table>`
68
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="${bg}${radius}${pad}"><tr>${children.map((child) => `<td width="${colWidth}%" valign="top" style="padding:${(p.gap as number | undefined) ? `0 ${(p.gap as number) / 2}px` : '0'}">${renderNode(child)}</td>`).join('')}</tr></table>`
69
69
  }
70
70
 
71
71
  return wrapInTable(renderChildren(node.children), `${bg}${radius}${pad}`)
72
72
  }
73
73
 
74
- case "row": {
75
- const children = node.children.filter((c): c is DocNode => typeof c !== "string")
74
+ case 'row': {
75
+ const children = node.children.filter((c): c is DocNode => typeof c !== 'string')
76
76
  const gap = (p.gap as number) ?? 0
77
- return `<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>${children.map((child) => `<td valign="top" style="padding:0 ${gap / 2}px">${renderNode(child)}</td>`).join("")}</tr></table>`
77
+ return `<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>${children.map((child) => `<td valign="top" style="padding:0 ${gap / 2}px">${renderNode(child)}</td>`).join('')}</tr></table>`
78
78
  }
79
79
 
80
- case "column":
80
+ case 'column':
81
81
  return renderChildren(node.children)
82
82
 
83
- case "heading": {
83
+ case 'heading': {
84
84
  const level = (p.level as number) ?? 1
85
85
  const sizes: Record<number, number> = {
86
86
  1: 28,
@@ -91,37 +91,37 @@ function renderNode(node: DocNode): string {
91
91
  6: 14,
92
92
  }
93
93
  const size = sizes[level] ?? 24
94
- const color = sanitizeColor((p.color as string) ?? "#000000")
95
- const align = (p.align as string) ?? "left"
94
+ const color = sanitizeColor((p.color as string) ?? '#000000')
95
+ const align = (p.align as string) ?? 'left'
96
96
  return `<h${level} style="margin:0 0 12px 0;font-size:${size}px;color:${color};text-align:${align};font-weight:bold;line-height:1.3">${renderChildren(node.children)}</h${level}>`
97
97
  }
98
98
 
99
- case "text": {
99
+ case 'text': {
100
100
  const size = (p.size as number) ?? 14
101
- const color = sanitizeColor((p.color as string) ?? "#333333")
102
- const weight = p.bold ? "bold" : "normal"
103
- const style = p.italic ? "italic" : "normal"
104
- const decoration = p.underline ? "underline" : p.strikethrough ? "line-through" : "none"
105
- const align = (p.align as string) ?? "left"
101
+ const color = sanitizeColor((p.color as string) ?? '#333333')
102
+ const weight = p.bold ? 'bold' : 'normal'
103
+ const style = p.italic ? 'italic' : 'normal'
104
+ const decoration = p.underline ? 'underline' : p.strikethrough ? 'line-through' : 'none'
105
+ const align = (p.align as string) ?? 'left'
106
106
  const lh = (p.lineHeight as number) ?? 1.5
107
107
  return `<p style="margin:0 0 12px 0;font-size:${size}px;color:${color};font-weight:${weight};font-style:${style};text-decoration:${decoration};text-align:${align};line-height:${lh}">${renderChildren(node.children)}</p>`
108
108
  }
109
109
 
110
- case "link":
111
- return `<a href="${esc(sanitizeHref(p.href as string))}" style="color:${sanitizeColor((p.color as string) ?? "#4f46e5")};text-decoration:underline" target="_blank">${renderChildren(node.children)}</a>`
110
+ case 'link':
111
+ return `<a href="${esc(sanitizeHref(p.href as string))}" style="color:${sanitizeColor((p.color as string) ?? '#4f46e5')};text-decoration:underline" target="_blank">${renderChildren(node.children)}</a>`
112
112
 
113
- case "image": {
114
- const align = (p.align as string) ?? "left"
115
- const img = `<img src="${esc(sanitizeImageSrc(p.src as string))}"${p.width ? ` width="${p.width}"` : ""}${p.height ? ` height="${p.height}"` : ""} alt="${esc((p.alt as string) ?? "")}" style="display:block;outline:none;border:none;text-decoration:none${p.width ? `;max-width:${p.width}px` : ""}" />`
113
+ case 'image': {
114
+ const align = (p.align as string) ?? 'left'
115
+ const img = `<img src="${esc(sanitizeImageSrc(p.src as string))}"${p.width ? ` width="${p.width}"` : ''}${p.height ? ` height="${p.height}"` : ''} alt="${esc((p.alt as string) ?? '')}" style="display:block;outline:none;border:none;text-decoration:none${p.width ? `;max-width:${p.width}px` : ''}" />`
116
116
  if (p.caption) {
117
- return `<table cellpadding="0" cellspacing="0" border="0"${align === "center" ? ' align="center"' : ""}><tr><td>${img}</td></tr><tr><td style="font-size:12px;color:#666;padding-top:4px;text-align:center">${esc(p.caption as string)}</td></tr></table>`
117
+ return `<table cellpadding="0" cellspacing="0" border="0"${align === 'center' ? ' align="center"' : ''}><tr><td>${img}</td></tr><tr><td style="font-size:12px;color:#666;padding-top:4px;text-align:center">${esc(p.caption as string)}</td></tr></table>`
118
118
  }
119
- if (align === "center") return `<div style="text-align:center">${img}</div>`
120
- if (align === "right") return `<div style="text-align:right">${img}</div>`
119
+ if (align === 'center') return `<div style="text-align:center">${img}</div>`
120
+ if (align === 'right') return `<div style="text-align:right">${img}</div>`
121
121
  return img
122
122
  }
123
123
 
124
- case "table": {
124
+ case 'table': {
125
125
  const columns = ((p.columns ?? []) as (string | TableColumn)[]).map(resolveColumn)
126
126
  const rows = (p.rows ?? []) as (string | number)[][]
127
127
  const hs = p.headerStyle as
@@ -134,71 +134,71 @@ function renderNode(node: DocNode): string {
134
134
  if (p.caption)
135
135
  html += `<caption style="font-size:12px;color:#666;padding:8px;text-align:left">${esc(p.caption as string)}</caption>`
136
136
 
137
- html += "<tr>"
137
+ html += '<tr>'
138
138
  for (const col of columns) {
139
139
  const bg = hs?.background
140
140
  ? `background-color:${sanitizeColor(hs.background)};`
141
- : "background-color:#f5f5f5;"
142
- const color = hs?.color ? `color:${sanitizeColor(hs.color)};` : ""
143
- const align = col.align ? `text-align:${col.align};` : ""
141
+ : 'background-color:#f5f5f5;'
142
+ const color = hs?.color ? `color:${sanitizeColor(hs.color)};` : ''
143
+ const align = col.align ? `text-align:${col.align};` : ''
144
144
  const width = col.width
145
- ? `width:${typeof col.width === "number" ? `${col.width}px` : col.width};`
146
- : ""
145
+ ? `width:${typeof col.width === 'number' ? `${col.width}px` : col.width};`
146
+ : ''
147
147
  html += `<th style="${bg}${color}font-weight:bold;${align}${width}padding:8px;border-bottom:2px solid #ddd">${esc(col.header)}</th>`
148
148
  }
149
- html += "</tr>"
149
+ html += '</tr>'
150
150
 
151
151
  for (let i = 0; i < rows.length; i++) {
152
- const bg = striped && i % 2 === 1 ? "background-color:#f9f9f9;" : ""
153
- html += "<tr>"
152
+ const bg = striped && i % 2 === 1 ? 'background-color:#f9f9f9;' : ''
153
+ html += '<tr>'
154
154
  for (let j = 0; j < columns.length; j++) {
155
155
  const col = columns[j]
156
- const align = col?.align ? `text-align:${col.align};` : ""
157
- html += `<td style="${bg}${align}padding:8px;border-bottom:1px solid #eee">${esc(String(rows[i]?.[j] ?? ""))}</td>`
156
+ const align = col?.align ? `text-align:${col.align};` : ''
157
+ html += `<td style="${bg}${align}padding:8px;border-bottom:1px solid #eee">${esc(String(rows[i]?.[j] ?? ''))}</td>`
158
158
  }
159
- html += "</tr>"
159
+ html += '</tr>'
160
160
  }
161
- html += "</table>"
161
+ html += '</table>'
162
162
  return html
163
163
  }
164
164
 
165
- case "list": {
166
- const tag = p.ordered ? "ol" : "ul"
165
+ case 'list': {
166
+ const tag = p.ordered ? 'ol' : 'ul'
167
167
  return `<${tag} style="margin:0 0 12px 0;padding-left:24px">${renderChildren(node.children)}</${tag}>`
168
168
  }
169
169
 
170
- case "list-item":
170
+ case 'list-item':
171
171
  return `<li style="margin:0 0 4px 0;font-size:14px;color:#333">${renderChildren(node.children)}</li>`
172
172
 
173
- case "code":
173
+ case 'code':
174
174
  return `<pre style="background-color:#f5f5f5;padding:12px;border-radius:4px;font-family:Courier New,monospace;font-size:13px;color:#333;overflow-x:auto;margin:0 0 12px 0"><code>${esc(renderChildren(node.children))}</code></pre>`
175
175
 
176
- case "divider": {
177
- const color = sanitizeColor((p.color as string) ?? "#dddddd")
176
+ case 'divider': {
177
+ const color = sanitizeColor((p.color as string) ?? '#dddddd')
178
178
  const thickness = (p.thickness as number) ?? 1
179
179
  return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0"><tr><td style="border-top:${thickness}px solid ${color};font-size:0;line-height:0">&nbsp;</td></tr></table>`
180
180
  }
181
181
 
182
- case "page-break":
182
+ case 'page-break':
183
183
  return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0"><tr><td style="border-top:2px solid #dddddd;font-size:0;line-height:0">&nbsp;</td></tr></table>`
184
184
 
185
- case "spacer":
185
+ case 'spacer':
186
186
  return `<div style="height:${p.height}px;line-height:${p.height}px;font-size:0">&nbsp;</div>`
187
187
 
188
- case "button": {
189
- const bg = sanitizeColor((p.background as string) ?? "#4f46e5")
190
- const color = sanitizeColor((p.color as string) ?? "#ffffff")
188
+ case 'button': {
189
+ const bg = sanitizeColor((p.background as string) ?? '#4f46e5')
190
+ const color = sanitizeColor((p.color as string) ?? '#ffffff')
191
191
  const radius = (p.borderRadius as number) ?? 4
192
192
  const href = esc(sanitizeHref(p.href as string))
193
193
  const text = renderChildren(node.children)
194
- const align = (p.align as string) ?? "left"
194
+ const align = (p.align as string) ?? 'left'
195
195
 
196
196
  // Bulletproof button — works in Outlook via VML, CSS everywhere else
197
197
  return `<div style="text-align:${align};margin:12px 0"><!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${href}" style="height:44px;v-text-anchor:middle;width:200px" arcsize="10%" strokecolor="${bg}" fillcolor="${bg}"><w:anchorlock/><center style="color:${color};font-family:Arial,sans-serif;font-size:14px;font-weight:bold">${text}</center></v:roundrect><![endif]--><!--[if !mso]><!--><a href="${href}" style="display:inline-block;background-color:${bg};color:${color};padding:12px 24px;border-radius:${radius}px;text-decoration:none;font-weight:bold;font-size:14px;font-family:Arial,sans-serif" target="_blank">${text}</a><!--<![endif]--></div>`
198
198
  }
199
199
 
200
- case "quote": {
201
- const borderColor = sanitizeColor((p.borderColor as string) ?? "#dddddd")
200
+ case 'quote': {
201
+ const borderColor = sanitizeColor((p.borderColor as string) ?? '#dddddd')
202
202
  return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:12px 0"><tr><td style="border-left:4px solid ${borderColor};padding:12px 20px;color:#555555;font-style:italic">${renderChildren(node.children)}</td></tr></table>`
203
203
  }
204
204