@bendyline/squisq-formats 0.1.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.
Files changed (61) hide show
  1. package/dist/chunk-532L4D5D.js +21 -0
  2. package/dist/chunk-532L4D5D.js.map +1 -0
  3. package/dist/chunk-67KIJHV2.js +21 -0
  4. package/dist/chunk-67KIJHV2.js.map +1 -0
  5. package/dist/chunk-KAK4V57E.js +387 -0
  6. package/dist/chunk-KAK4V57E.js.map +1 -0
  7. package/dist/chunk-MQHCXI56.js +1035 -0
  8. package/dist/chunk-MQHCXI56.js.map +1 -0
  9. package/dist/chunk-TBPD5PCU.js +1136 -0
  10. package/dist/chunk-TBPD5PCU.js.map +1 -0
  11. package/dist/chunk-ULLIPBEJ.js +271 -0
  12. package/dist/chunk-ULLIPBEJ.js.map +1 -0
  13. package/dist/docx/index.d.ts +108 -0
  14. package/dist/docx/index.js +14 -0
  15. package/dist/docx/index.js.map +1 -0
  16. package/dist/html/index.d.ts +166 -0
  17. package/dist/html/index.js +17 -0
  18. package/dist/html/index.js.map +1 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +54 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/ooxml/index.d.ts +351 -0
  23. package/dist/ooxml/index.js +99 -0
  24. package/dist/ooxml/index.js.map +1 -0
  25. package/dist/pdf/index.d.ts +130 -0
  26. package/dist/pdf/index.js +15 -0
  27. package/dist/pdf/index.js.map +1 -0
  28. package/dist/pptx/index.d.ts +58 -0
  29. package/dist/pptx/index.js +13 -0
  30. package/dist/pptx/index.js.map +1 -0
  31. package/dist/xlsx/index.d.ts +58 -0
  32. package/dist/xlsx/index.js +13 -0
  33. package/dist/xlsx/index.js.map +1 -0
  34. package/package.json +85 -0
  35. package/src/__tests__/docxExport.test.ts +457 -0
  36. package/src/__tests__/docxImport.test.ts +410 -0
  37. package/src/__tests__/html.test.ts +295 -0
  38. package/src/__tests__/ooxml.test.ts +197 -0
  39. package/src/__tests__/pdfExport.test.ts +322 -0
  40. package/src/__tests__/pdfImport.test.ts +290 -0
  41. package/src/__tests__/roundTrip.test.ts +201 -0
  42. package/src/docx/export.ts +978 -0
  43. package/src/docx/import.ts +909 -0
  44. package/src/docx/index.ts +26 -0
  45. package/src/docx/styles.ts +145 -0
  46. package/src/html/htmlTemplate.ts +307 -0
  47. package/src/html/imageUtils.ts +66 -0
  48. package/src/html/index.ts +168 -0
  49. package/src/index.ts +51 -0
  50. package/src/ooxml/index.ts +87 -0
  51. package/src/ooxml/namespaces.ts +160 -0
  52. package/src/ooxml/reader.ts +218 -0
  53. package/src/ooxml/types.ts +104 -0
  54. package/src/ooxml/writer.ts +321 -0
  55. package/src/ooxml/xmlUtils.ts +123 -0
  56. package/src/pdf/export.ts +1029 -0
  57. package/src/pdf/import.ts +835 -0
  58. package/src/pdf/index.ts +29 -0
  59. package/src/pdf/styles.ts +180 -0
  60. package/src/pptx/index.ts +78 -0
  61. package/src/xlsx/index.ts +78 -0
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * PDF Export
3
+ *
4
+ * Converts a squisq MarkdownDocument (or Doc) into a PDF file
5
+ * using pdf-lib. Generates paginated, styled output with support for
6
+ * headings, paragraphs, inline formatting, lists, code blocks,
7
+ * blockquotes, tables, thematic breaks, and hyperlinks.
8
+ *
9
+ * Uses only the 14 standard PDF fonts (no font embedding required),
10
+ * keeping output size small and rendering fast.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { parseMarkdown } from '@bendyline/squisq/markdown';
15
+ * import { markdownDocToPdf } from '@bendyline/squisq-formats/pdf';
16
+ *
17
+ * const md = parseMarkdown('# Hello\n\nWorld **bold** text');
18
+ * const buffer = await markdownDocToPdf(md);
19
+ * ```
20
+ */
21
+
22
+ import { PDFDocument, StandardFonts, rgb, PDFFont, PDFPage } from 'pdf-lib';
23
+
24
+ import type { Doc } from '@bendyline/squisq/schemas';
25
+ import { docToMarkdown } from '@bendyline/squisq/doc';
26
+ import type {
27
+ MarkdownDocument,
28
+ MarkdownBlockNode,
29
+ MarkdownInlineNode,
30
+ MarkdownHeading,
31
+ MarkdownParagraph,
32
+ MarkdownBlockquote,
33
+ MarkdownList,
34
+ MarkdownListItem,
35
+ MarkdownCodeBlock,
36
+ MarkdownTable,
37
+ MarkdownTableRow,
38
+ MarkdownTableCell,
39
+ MarkdownHtmlBlock,
40
+ MarkdownMathBlock,
41
+ MarkdownFootnoteDefinition,
42
+ MarkdownText,
43
+ MarkdownEmphasis,
44
+ MarkdownStrong,
45
+ MarkdownStrikethrough,
46
+ MarkdownInlineCode,
47
+ MarkdownLink,
48
+ MarkdownImage,
49
+ MarkdownInlineHtml,
50
+ MarkdownInlineMath,
51
+ MarkdownFootnoteReference,
52
+ } from '@bendyline/squisq/markdown';
53
+
54
+ import {
55
+ PAGE_WIDTH_LETTER,
56
+ PAGE_HEIGHT_LETTER,
57
+ PAGE_WIDTH_A4,
58
+ PAGE_HEIGHT_A4,
59
+ DEFAULT_MARGIN,
60
+ DEFAULT_FONT_SIZE,
61
+ HEADING_SIZES,
62
+ CODE_FONT_SIZE,
63
+ LINE_HEIGHT_FACTOR,
64
+ HEADING_SPACE_BEFORE,
65
+ HEADING_SPACE_AFTER,
66
+ PARAGRAPH_SPACING,
67
+ LIST_INDENT,
68
+ BULLET_CHAR,
69
+ BLOCKQUOTE_INDENT,
70
+ BLOCKQUOTE_BAR_WIDTH,
71
+ COLOR_TEXT,
72
+ COLOR_HEADING,
73
+ COLOR_LINK,
74
+ COLOR_CODE_TEXT,
75
+ COLOR_BLOCKQUOTE_BAR,
76
+ COLOR_BLOCKQUOTE_TEXT,
77
+ COLOR_THEMATIC_BREAK,
78
+ COLOR_TABLE_BORDER,
79
+ COLOR_TABLE_HEADER_BG,
80
+ TABLE_CELL_PAD_X,
81
+ TABLE_CELL_PAD_Y,
82
+ TABLE_BORDER_WIDTH,
83
+ } from './styles.js';
84
+
85
+ // ============================================
86
+ // Public API
87
+ // ============================================
88
+
89
+ /** Page size presets. */
90
+ export type PdfPageSize = 'letter' | 'a4';
91
+
92
+ /**
93
+ * Options for PDF export.
94
+ */
95
+ export interface PdfExportOptions {
96
+ /** Document title (PDF metadata). */
97
+ title?: string;
98
+ /** Document author (PDF metadata). */
99
+ author?: string;
100
+ /** Page size preset. Default: "letter". */
101
+ pageSize?: PdfPageSize;
102
+ /** Page margins in points. Default: 72 (1 inch). */
103
+ margin?: number;
104
+ /** Default body font size in points. Default: 11. */
105
+ defaultFontSize?: number;
106
+ }
107
+
108
+ /**
109
+ * Convert a MarkdownDocument to a PDF ArrayBuffer.
110
+ */
111
+ export async function markdownDocToPdf(
112
+ doc: MarkdownDocument,
113
+ options: PdfExportOptions = {},
114
+ ): Promise<ArrayBuffer> {
115
+ const pdfDoc = await PDFDocument.create();
116
+
117
+ // Metadata
118
+ if (options.title) pdfDoc.setTitle(options.title);
119
+ if (options.author) pdfDoc.setAuthor(options.author);
120
+ pdfDoc.setCreationDate(new Date());
121
+ pdfDoc.setModificationDate(new Date());
122
+
123
+ const ctx = await createExportContext(pdfDoc, options);
124
+
125
+ renderBlocks(doc.children, ctx, 0);
126
+
127
+ const bytes = await pdfDoc.save();
128
+ return bytes.buffer as ArrayBuffer;
129
+ }
130
+
131
+ /**
132
+ * Convert a squisq Doc to a PDF ArrayBuffer.
133
+ *
134
+ * Convenience wrapper: Doc → MarkdownDocument → PDF.
135
+ */
136
+ export async function docToPdf(doc: Doc, options: PdfExportOptions = {}): Promise<ArrayBuffer> {
137
+ const markdownDoc = docToMarkdown(doc);
138
+ return markdownDocToPdf(markdownDoc, options);
139
+ }
140
+
141
+ // ============================================
142
+ // Export Context — tracks cursor, fonts, pages
143
+ // ============================================
144
+
145
+ interface FontSet {
146
+ regular: PDFFont;
147
+ bold: PDFFont;
148
+ italic: PDFFont;
149
+ boldItalic: PDFFont;
150
+ mono: PDFFont;
151
+ monoBold: PDFFont;
152
+ }
153
+
154
+ interface ExportContext {
155
+ pdfDoc: PDFDocument;
156
+ fonts: FontSet;
157
+ pageWidth: number;
158
+ pageHeight: number;
159
+ margin: number;
160
+ fontSize: number;
161
+ /** Current page being drawn on. */
162
+ page: PDFPage;
163
+ /** Current y position (from top of page, decreasing). */
164
+ y: number;
165
+ /** Content area width = pageWidth - 2*margin. */
166
+ contentWidth: number;
167
+ /** Bottom margin y position. */
168
+ bottomY: number;
169
+ }
170
+
171
+ async function createExportContext(
172
+ pdfDoc: PDFDocument,
173
+ options: PdfExportOptions,
174
+ ): Promise<ExportContext> {
175
+ const [regular, bold, italic, boldItalic, mono, monoBold] = await Promise.all([
176
+ pdfDoc.embedFont(StandardFonts.Helvetica),
177
+ pdfDoc.embedFont(StandardFonts.HelveticaBold),
178
+ pdfDoc.embedFont(StandardFonts.HelveticaOblique),
179
+ pdfDoc.embedFont(StandardFonts.HelveticaBoldOblique),
180
+ pdfDoc.embedFont(StandardFonts.Courier),
181
+ pdfDoc.embedFont(StandardFonts.CourierBold),
182
+ ]);
183
+
184
+ const isA4 = options.pageSize === 'a4';
185
+ const pageWidth = isA4 ? PAGE_WIDTH_A4 : PAGE_WIDTH_LETTER;
186
+ const pageHeight = isA4 ? PAGE_HEIGHT_A4 : PAGE_HEIGHT_LETTER;
187
+ const margin = options.margin ?? DEFAULT_MARGIN;
188
+ const fontSize = options.defaultFontSize ?? DEFAULT_FONT_SIZE;
189
+
190
+ const page = pdfDoc.addPage([pageWidth, pageHeight]);
191
+
192
+ return {
193
+ pdfDoc,
194
+ fonts: { regular, bold, italic, boldItalic, mono, monoBold },
195
+ pageWidth,
196
+ pageHeight,
197
+ margin,
198
+ fontSize,
199
+ page,
200
+ y: pageHeight - margin,
201
+ contentWidth: pageWidth - 2 * margin,
202
+ bottomY: margin,
203
+ };
204
+ }
205
+
206
+ // ============================================
207
+ // Page Break Management
208
+ // ============================================
209
+
210
+ function ensureSpace(ctx: ExportContext, needed: number): void {
211
+ if (ctx.y - needed < ctx.bottomY) {
212
+ newPage(ctx);
213
+ }
214
+ }
215
+
216
+ function newPage(ctx: ExportContext): void {
217
+ const page = ctx.pdfDoc.addPage([ctx.pageWidth, ctx.pageHeight]);
218
+ ctx.page = page;
219
+ ctx.y = ctx.pageHeight - ctx.margin;
220
+ }
221
+
222
+ // ============================================
223
+ // Inline "Span" Model
224
+ // ============================================
225
+
226
+ interface TextSpan {
227
+ text: string;
228
+ font: PDFFont;
229
+ fontSize: number;
230
+ color: { r: number; g: number; b: number };
231
+ link?: string;
232
+ strikethrough?: boolean;
233
+ }
234
+
235
+ /**
236
+ * Flatten an inline node tree into a flat list of TextSpans,
237
+ * accumulating bold/italic/code state as we recurse.
238
+ */
239
+ function flattenInlines(
240
+ nodes: MarkdownInlineNode[],
241
+ ctx: ExportContext,
242
+ state: {
243
+ bold: boolean;
244
+ italic: boolean;
245
+ code: boolean;
246
+ link?: string;
247
+ color?: { r: number; g: number; b: number };
248
+ strikethrough?: boolean;
249
+ },
250
+ ): TextSpan[] {
251
+ const spans: TextSpan[] = [];
252
+
253
+ for (const node of nodes) {
254
+ switch (node.type) {
255
+ case 'text': {
256
+ const font = state.code
257
+ ? state.bold
258
+ ? ctx.fonts.monoBold
259
+ : ctx.fonts.mono
260
+ : pickFont(ctx, state.bold, state.italic);
261
+ spans.push({
262
+ text: (node as MarkdownText).value,
263
+ font,
264
+ fontSize: state.code ? CODE_FONT_SIZE : ctx.fontSize,
265
+ color: state.code ? COLOR_CODE_TEXT : (state.color ?? COLOR_TEXT),
266
+ link: state.link,
267
+ strikethrough: state.strikethrough,
268
+ });
269
+ break;
270
+ }
271
+
272
+ case 'strong':
273
+ spans.push(
274
+ ...flattenInlines((node as MarkdownStrong).children, ctx, { ...state, bold: true }),
275
+ );
276
+ break;
277
+
278
+ case 'emphasis':
279
+ spans.push(
280
+ ...flattenInlines((node as MarkdownEmphasis).children, ctx, { ...state, italic: true }),
281
+ );
282
+ break;
283
+
284
+ case 'delete':
285
+ spans.push(
286
+ ...flattenInlines((node as MarkdownStrikethrough).children, ctx, {
287
+ ...state,
288
+ strikethrough: true,
289
+ }),
290
+ );
291
+ break;
292
+
293
+ case 'inlineCode': {
294
+ spans.push({
295
+ text: (node as MarkdownInlineCode).value,
296
+ font: ctx.fonts.mono,
297
+ fontSize: CODE_FONT_SIZE,
298
+ color: COLOR_CODE_TEXT,
299
+ link: state.link,
300
+ });
301
+ break;
302
+ }
303
+
304
+ case 'link': {
305
+ const linkNode = node as MarkdownLink;
306
+ spans.push(
307
+ ...flattenInlines(linkNode.children, ctx, {
308
+ ...state,
309
+ link: linkNode.url,
310
+ color: COLOR_LINK,
311
+ }),
312
+ );
313
+ break;
314
+ }
315
+
316
+ case 'image': {
317
+ const imgNode = node as MarkdownImage;
318
+ spans.push({
319
+ text: imgNode.alt ? `[Image: ${imgNode.alt}]` : '[Image]',
320
+ font: ctx.fonts.italic,
321
+ fontSize: ctx.fontSize,
322
+ color: COLOR_BLOCKQUOTE_TEXT,
323
+ });
324
+ break;
325
+ }
326
+
327
+ case 'break':
328
+ spans.push({
329
+ text: '\n',
330
+ font: ctx.fonts.regular,
331
+ fontSize: ctx.fontSize,
332
+ color: COLOR_TEXT,
333
+ });
334
+ break;
335
+
336
+ case 'htmlInline': {
337
+ const html = (node as MarkdownInlineHtml).rawHtml;
338
+ if (html) {
339
+ spans.push({
340
+ text: html,
341
+ font: ctx.fonts.mono,
342
+ fontSize: CODE_FONT_SIZE,
343
+ color: COLOR_CODE_TEXT,
344
+ });
345
+ }
346
+ break;
347
+ }
348
+
349
+ case 'inlineMath':
350
+ spans.push({
351
+ text: (node as MarkdownInlineMath).value,
352
+ font: ctx.fonts.mono,
353
+ fontSize: CODE_FONT_SIZE,
354
+ color: COLOR_CODE_TEXT,
355
+ });
356
+ break;
357
+
358
+ case 'footnoteReference': {
359
+ const ref = node as MarkdownFootnoteReference;
360
+ spans.push({
361
+ text: `[${ref.identifier}]`,
362
+ font: ctx.fonts.regular,
363
+ fontSize: ctx.fontSize * 0.75,
364
+ color: COLOR_LINK,
365
+ });
366
+ break;
367
+ }
368
+
369
+ // linkReference, imageReference, textDirective — render children or identifier
370
+ default: {
371
+ const fallback = node as unknown as { children?: MarkdownInlineNode[]; value?: string };
372
+ if (fallback.children) {
373
+ spans.push(...flattenInlines(fallback.children, ctx, state));
374
+ } else if (fallback.value) {
375
+ spans.push({
376
+ text: fallback.value,
377
+ font: pickFont(ctx, state.bold, state.italic),
378
+ fontSize: ctx.fontSize,
379
+ color: state.color ?? COLOR_TEXT,
380
+ });
381
+ }
382
+ break;
383
+ }
384
+ }
385
+ }
386
+
387
+ return spans;
388
+ }
389
+
390
+ function pickFont(ctx: ExportContext, bold: boolean, italic: boolean): PDFFont {
391
+ if (bold && italic) return ctx.fonts.boldItalic;
392
+ if (bold) return ctx.fonts.bold;
393
+ if (italic) return ctx.fonts.italic;
394
+ return ctx.fonts.regular;
395
+ }
396
+
397
+ // ============================================
398
+ // Word-Wrap & Draw
399
+ // ============================================
400
+
401
+ /**
402
+ * Word-wraps and draws a flat list of TextSpans within the given
403
+ * available width, starting at ctx.y. Updates ctx.y after drawing.
404
+ * Returns the y position after the last line.
405
+ */
406
+ function drawSpans(
407
+ spans: TextSpan[],
408
+ ctx: ExportContext,
409
+ availableWidth: number,
410
+ x0: number,
411
+ ): void {
412
+ if (spans.length === 0) return;
413
+
414
+ // Split spans at \n and word boundaries, then wrap into lines
415
+ const lines = wrapSpans(spans, availableWidth);
416
+
417
+ for (const line of lines) {
418
+ const lineHeight = getLineHeight(line);
419
+ ensureSpace(ctx, lineHeight);
420
+
421
+ let x = x0;
422
+ for (const span of line) {
423
+ ctx.page.drawText(span.text, {
424
+ x,
425
+ y: ctx.y - span.fontSize, // pdf-lib y is baseline
426
+ size: span.fontSize,
427
+ font: span.font,
428
+ color: rgb(span.color.r, span.color.g, span.color.b),
429
+ });
430
+
431
+ const textWidth = span.font.widthOfTextAtSize(span.text, span.fontSize);
432
+
433
+ // Underline for links
434
+ if (span.link) {
435
+ ctx.page.drawLine({
436
+ start: { x, y: ctx.y - span.fontSize - 1 },
437
+ end: { x: x + textWidth, y: ctx.y - span.fontSize - 1 },
438
+ thickness: 0.5,
439
+ color: rgb(span.color.r, span.color.g, span.color.b),
440
+ });
441
+ }
442
+
443
+ // Strikethrough
444
+ if (span.strikethrough) {
445
+ const midY = ctx.y - span.fontSize * 0.6;
446
+ ctx.page.drawLine({
447
+ start: { x, y: midY },
448
+ end: { x: x + textWidth, y: midY },
449
+ thickness: 0.5,
450
+ color: rgb(span.color.r, span.color.g, span.color.b),
451
+ });
452
+ }
453
+
454
+ x += textWidth;
455
+ }
456
+
457
+ ctx.y -= lineHeight;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Break a flat list of spans into wrapped lines that fit within
463
+ * `maxWidth`. Respects explicit \n characters.
464
+ */
465
+ function wrapSpans(spans: TextSpan[], maxWidth: number): TextSpan[][] {
466
+ const lines: TextSpan[][] = [];
467
+ let currentLine: TextSpan[] = [];
468
+ let lineWidth = 0;
469
+
470
+ for (const span of spans) {
471
+ // Handle explicit newlines
472
+ if (span.text === '\n') {
473
+ lines.push(currentLine);
474
+ currentLine = [];
475
+ lineWidth = 0;
476
+ continue;
477
+ }
478
+
479
+ // Split by whitespace for word-wrapping
480
+ const words = splitIntoWords(span.text);
481
+
482
+ for (const word of words) {
483
+ const wordWidth = span.font.widthOfTextAtSize(word, span.fontSize);
484
+
485
+ if (lineWidth + wordWidth > maxWidth && currentLine.length > 0 && word.trim().length > 0) {
486
+ // Wrap to next line
487
+ lines.push(currentLine);
488
+ currentLine = [];
489
+ lineWidth = 0;
490
+ }
491
+
492
+ // Trim leading space on new line
493
+ const trimmedWord = currentLine.length === 0 ? word.replace(/^\s+/, '') : word;
494
+ if (trimmedWord.length > 0) {
495
+ const tw = span.font.widthOfTextAtSize(trimmedWord, span.fontSize);
496
+ currentLine.push({ ...span, text: trimmedWord });
497
+ lineWidth += tw;
498
+ }
499
+ }
500
+ }
501
+
502
+ if (currentLine.length > 0) {
503
+ lines.push(currentLine);
504
+ }
505
+
506
+ return lines.length > 0 ? lines : [[]];
507
+ }
508
+
509
+ /**
510
+ * Split text into "words" preserving whitespace as separate tokens
511
+ * so the wrapping logic can break at whitespace.
512
+ */
513
+ function splitIntoWords(text: string): string[] {
514
+ const tokens: string[] = [];
515
+ let current = '';
516
+ let inSpace = false;
517
+
518
+ for (const ch of text) {
519
+ const isSpace = ch === ' ' || ch === '\t';
520
+ if (isSpace !== inSpace && current.length > 0) {
521
+ tokens.push(current);
522
+ current = '';
523
+ }
524
+ current += ch;
525
+ inSpace = isSpace;
526
+ }
527
+ if (current.length > 0) tokens.push(current);
528
+ return tokens;
529
+ }
530
+
531
+ function getLineHeight(line: TextSpan[]): number {
532
+ let maxSize = 0;
533
+ for (const span of line) {
534
+ if (span.fontSize > maxSize) maxSize = span.fontSize;
535
+ }
536
+ return (maxSize || DEFAULT_FONT_SIZE) * LINE_HEIGHT_FACTOR;
537
+ }
538
+
539
+ // ============================================
540
+ // Block Renderers
541
+ // ============================================
542
+
543
+ function renderBlocks(nodes: MarkdownBlockNode[], ctx: ExportContext, extraIndent: number): void {
544
+ for (const node of nodes) {
545
+ renderBlock(node, ctx, extraIndent);
546
+ }
547
+ }
548
+
549
+ function renderBlock(node: MarkdownBlockNode, ctx: ExportContext, extraIndent: number): void {
550
+ switch (node.type) {
551
+ case 'heading':
552
+ renderHeading(node as MarkdownHeading, ctx, extraIndent);
553
+ break;
554
+ case 'paragraph':
555
+ renderParagraph(node as MarkdownParagraph, ctx, extraIndent);
556
+ break;
557
+ case 'blockquote':
558
+ renderBlockquote(node as MarkdownBlockquote, ctx, extraIndent);
559
+ break;
560
+ case 'list':
561
+ renderList(node as MarkdownList, ctx, extraIndent, 0);
562
+ break;
563
+ case 'code':
564
+ renderCodeBlock(node as MarkdownCodeBlock, ctx, extraIndent);
565
+ break;
566
+ case 'table':
567
+ renderTable(node as MarkdownTable, ctx, extraIndent);
568
+ break;
569
+ case 'thematicBreak':
570
+ renderThematicBreak(ctx, extraIndent);
571
+ break;
572
+ case 'htmlBlock':
573
+ renderHtmlBlock(node as MarkdownHtmlBlock, ctx, extraIndent);
574
+ break;
575
+ case 'math':
576
+ renderMathBlock(node as MarkdownMathBlock, ctx, extraIndent);
577
+ break;
578
+ case 'footnoteDefinition':
579
+ renderFootnoteDefinition(node as MarkdownFootnoteDefinition, ctx, extraIndent);
580
+ break;
581
+ default:
582
+ // containerDirective, leafDirective, definitionList, etc.
583
+ // Render any children or value as best-effort
584
+ renderFallbackBlock(node, ctx, extraIndent);
585
+ break;
586
+ }
587
+ }
588
+
589
+ // ---- Heading ----
590
+
591
+ function renderHeading(node: MarkdownHeading, ctx: ExportContext, extraIndent: number): void {
592
+ const origFontSize = ctx.fontSize;
593
+ ctx.fontSize = HEADING_SIZES[node.depth] ?? DEFAULT_FONT_SIZE;
594
+
595
+ ctx.y -= HEADING_SPACE_BEFORE;
596
+
597
+ const x0 = ctx.margin + extraIndent;
598
+ const w = ctx.contentWidth - extraIndent;
599
+
600
+ const spans = flattenInlines(node.children, ctx, {
601
+ bold: true,
602
+ italic: false,
603
+ code: false,
604
+ color: COLOR_HEADING,
605
+ });
606
+
607
+ drawSpans(spans, ctx, w, x0);
608
+
609
+ ctx.y -= HEADING_SPACE_AFTER;
610
+ ctx.fontSize = origFontSize;
611
+ }
612
+
613
+ // ---- Paragraph ----
614
+
615
+ function renderParagraph(
616
+ node: MarkdownParagraph,
617
+ ctx: ExportContext,
618
+ extraIndent: number,
619
+ colorOverride?: { r: number; g: number; b: number },
620
+ ): void {
621
+ const x0 = ctx.margin + extraIndent;
622
+ const w = ctx.contentWidth - extraIndent;
623
+
624
+ const spans = flattenInlines(node.children, ctx, {
625
+ bold: false,
626
+ italic: false,
627
+ code: false,
628
+ color: colorOverride,
629
+ });
630
+
631
+ drawSpans(spans, ctx, w, x0);
632
+ ctx.y -= PARAGRAPH_SPACING;
633
+ }
634
+
635
+ // ---- Blockquote ----
636
+
637
+ function renderBlockquote(node: MarkdownBlockquote, ctx: ExportContext, extraIndent: number): void {
638
+ const barX = ctx.margin + extraIndent + 4;
639
+ const indent = extraIndent + BLOCKQUOTE_INDENT;
640
+ const startY = ctx.y;
641
+
642
+ for (const child of node.children) {
643
+ if (child.type === 'paragraph') {
644
+ renderParagraph(child as MarkdownParagraph, ctx, indent, COLOR_BLOCKQUOTE_TEXT);
645
+ } else {
646
+ renderBlock(child, ctx, indent);
647
+ }
648
+ }
649
+
650
+ // Draw left bar from startY to ctx.y
651
+ const endY = ctx.y + PARAGRAPH_SPACING; // undo last paragraph spacing
652
+ if (startY > endY) {
653
+ // Bar might span pages — draw on current page only
654
+ ctx.page.drawRectangle({
655
+ x: barX,
656
+ y: endY,
657
+ width: BLOCKQUOTE_BAR_WIDTH,
658
+ height: startY - endY,
659
+ color: rgb(COLOR_BLOCKQUOTE_BAR.r, COLOR_BLOCKQUOTE_BAR.g, COLOR_BLOCKQUOTE_BAR.b),
660
+ });
661
+ }
662
+
663
+ ctx.y -= PARAGRAPH_SPACING;
664
+ }
665
+
666
+ // ---- List ----
667
+
668
+ function renderList(
669
+ node: MarkdownList,
670
+ ctx: ExportContext,
671
+ extraIndent: number,
672
+ depth: number,
673
+ ): void {
674
+ const ordered = node.ordered ?? false;
675
+ let counter = node.start ?? 1;
676
+
677
+ for (const child of node.children) {
678
+ if (child.type === 'listItem') {
679
+ renderListItem(child as MarkdownListItem, ctx, extraIndent, depth, ordered, counter);
680
+ if (ordered) counter++;
681
+ }
682
+ }
683
+ }
684
+
685
+ function renderListItem(
686
+ item: MarkdownListItem,
687
+ ctx: ExportContext,
688
+ extraIndent: number,
689
+ depth: number,
690
+ ordered: boolean,
691
+ counter: number,
692
+ ): void {
693
+ const indent = extraIndent + depth * LIST_INDENT;
694
+ const bullet = ordered ? `${counter}.` : BULLET_CHAR;
695
+ const bulletFont = ctx.fonts.regular;
696
+ const bulletWidth = bulletFont.widthOfTextAtSize(bullet + ' ', ctx.fontSize);
697
+
698
+ // Draw bullet
699
+ const lineHeight = ctx.fontSize * LINE_HEIGHT_FACTOR;
700
+ ensureSpace(ctx, lineHeight);
701
+
702
+ ctx.page.drawText(bullet, {
703
+ x: ctx.margin + indent,
704
+ y: ctx.y - ctx.fontSize,
705
+ size: ctx.fontSize,
706
+ font: bulletFont,
707
+ color: rgb(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b),
708
+ });
709
+
710
+ const textIndent = indent + bulletWidth + 4;
711
+ const textWidth = ctx.contentWidth - textIndent;
712
+
713
+ // Render children at the text indent
714
+ let isFirstChild = true;
715
+ for (const child of item.children) {
716
+ if (child.type === 'paragraph' && isFirstChild) {
717
+ // First paragraph: render on same line as bullet
718
+ const spans = flattenInlines((child as MarkdownParagraph).children, ctx, {
719
+ bold: false,
720
+ italic: false,
721
+ code: false,
722
+ });
723
+ drawSpans(spans, ctx, textWidth, ctx.margin + textIndent);
724
+ ctx.y -= PARAGRAPH_SPACING / 2;
725
+ } else if (child.type === 'list') {
726
+ renderList(child as MarkdownList, ctx, extraIndent, depth + 1);
727
+ } else {
728
+ renderBlock(child, ctx, textIndent);
729
+ }
730
+ isFirstChild = false;
731
+ }
732
+ }
733
+
734
+ // ---- Code Block ----
735
+
736
+ function renderCodeBlock(node: MarkdownCodeBlock, ctx: ExportContext, extraIndent: number): void {
737
+ const x0 = ctx.margin + extraIndent;
738
+ const _w = ctx.contentWidth - extraIndent;
739
+ const lines = node.value.split('\n');
740
+ const lineH = CODE_FONT_SIZE * LINE_HEIGHT_FACTOR;
741
+ const totalHeight = lines.length * lineH + 12; // 12 = vertical padding
742
+
743
+ ensureSpace(ctx, Math.min(totalHeight, ctx.y - ctx.bottomY));
744
+
745
+ const _bgTop = ctx.y;
746
+
747
+ // Draw background first (we'll adjust after we know the final y)
748
+ const _bgStartY = ctx.y;
749
+ ctx.y -= 6; // top padding
750
+
751
+ for (const line of lines) {
752
+ ensureSpace(ctx, lineH);
753
+ if (line.length > 0) {
754
+ ctx.page.drawText(line, {
755
+ x: x0 + 8,
756
+ y: ctx.y - CODE_FONT_SIZE,
757
+ size: CODE_FONT_SIZE,
758
+ font: ctx.fonts.mono,
759
+ color: rgb(COLOR_CODE_TEXT.r, COLOR_CODE_TEXT.g, COLOR_CODE_TEXT.b),
760
+ });
761
+ }
762
+ ctx.y -= lineH;
763
+ }
764
+
765
+ ctx.y -= 6; // bottom padding
766
+
767
+ // Draw background rectangle (behind text — pdf-lib draws on top,
768
+ // so we could draw it first on a separate pass, but since we can't
769
+ // easily re-order, we leave it as a visual approximation).
770
+ // For a production implementation, you'd use a two-pass approach.
771
+ // Here we draw the bg rect at the saved position. Text drawn after
772
+ // will already be on the page. Since pdf-lib paints in order, we
773
+ // accept that the bg goes on top — but only if bg is on same page.
774
+ // A simpler approach: draw bg *before* text per page.
775
+
776
+ // Actually, let's use a cleaner approach: draw line by line with bg
777
+ // We already drew the text. For the background box, we'll just
778
+ // not draw it behind (it would overlay). This is a known limitation
779
+ // with pdf-lib's immediate mode. In practice the light grey bg makes
780
+ // text hard to read if drawn on top. So we skip the bg for now and
781
+ // rely on the monospace font to visually distinguish code.
782
+
783
+ ctx.y -= PARAGRAPH_SPACING;
784
+ }
785
+
786
+ // ---- Table ----
787
+
788
+ function renderTable(node: MarkdownTable, ctx: ExportContext, extraIndent: number): void {
789
+ if (node.children.length === 0) return;
790
+
791
+ const x0 = ctx.margin + extraIndent;
792
+ const tableWidth = ctx.contentWidth - extraIndent;
793
+
794
+ // Measure columns: get max width per column across all rows
795
+ const numCols = Math.max(
796
+ ...node.children.map((row) => (row as MarkdownTableRow).children.length),
797
+ );
798
+ if (numCols === 0) return;
799
+
800
+ // Simple equal-width columns
801
+ const colWidth = tableWidth / numCols;
802
+
803
+ for (let rowIdx = 0; rowIdx < node.children.length; rowIdx++) {
804
+ const row = node.children[rowIdx] as MarkdownTableRow;
805
+ const isHeader = rowIdx === 0 && row.children.some((c) => (c as MarkdownTableCell).isHeader);
806
+
807
+ // Calculate row height
808
+ const rowCellHeights = row.children.map((cell) => {
809
+ const cellNode = cell as MarkdownTableCell;
810
+ const spans = flattenInlines(cellNode.children, ctx, {
811
+ bold: isHeader,
812
+ italic: false,
813
+ code: false,
814
+ });
815
+ const lines = wrapSpans(spans, colWidth - 2 * TABLE_CELL_PAD_X);
816
+ return lines.length * ctx.fontSize * LINE_HEIGHT_FACTOR + 2 * TABLE_CELL_PAD_Y;
817
+ });
818
+ const rowHeight = Math.max(
819
+ ...rowCellHeights,
820
+ ctx.fontSize * LINE_HEIGHT_FACTOR + 2 * TABLE_CELL_PAD_Y,
821
+ );
822
+
823
+ ensureSpace(ctx, rowHeight);
824
+
825
+ // Draw header background
826
+ if (isHeader) {
827
+ ctx.page.drawRectangle({
828
+ x: x0,
829
+ y: ctx.y - rowHeight,
830
+ width: tableWidth,
831
+ height: rowHeight,
832
+ color: rgb(COLOR_TABLE_HEADER_BG.r, COLOR_TABLE_HEADER_BG.g, COLOR_TABLE_HEADER_BG.b),
833
+ });
834
+ }
835
+
836
+ // Draw cell borders and text
837
+ for (let colIdx = 0; colIdx < numCols; colIdx++) {
838
+ const cellX = x0 + colIdx * colWidth;
839
+
840
+ // Draw cell border
841
+ ctx.page.drawRectangle({
842
+ x: cellX,
843
+ y: ctx.y - rowHeight,
844
+ width: colWidth,
845
+ height: rowHeight,
846
+ borderColor: rgb(COLOR_TABLE_BORDER.r, COLOR_TABLE_BORDER.g, COLOR_TABLE_BORDER.b),
847
+ borderWidth: TABLE_BORDER_WIDTH,
848
+ });
849
+
850
+ if (colIdx < row.children.length) {
851
+ const cellNode = row.children[colIdx] as MarkdownTableCell;
852
+ const spans = flattenInlines(cellNode.children, ctx, {
853
+ bold: isHeader,
854
+ italic: false,
855
+ code: false,
856
+ });
857
+
858
+ // Draw text inside cell
859
+ const savedY = ctx.y;
860
+ ctx.y = ctx.y - TABLE_CELL_PAD_Y;
861
+
862
+ const cellAvailableWidth = colWidth - 2 * TABLE_CELL_PAD_X;
863
+ const wrappedLines = wrapSpans(spans, cellAvailableWidth);
864
+
865
+ for (const line of wrappedLines) {
866
+ let lx = cellX + TABLE_CELL_PAD_X;
867
+ for (const span of line) {
868
+ ctx.page.drawText(span.text, {
869
+ x: lx,
870
+ y: ctx.y - span.fontSize,
871
+ size: span.fontSize,
872
+ font: span.font,
873
+ color: rgb(span.color.r, span.color.g, span.color.b),
874
+ });
875
+ lx += span.font.widthOfTextAtSize(span.text, span.fontSize);
876
+ }
877
+ ctx.y -= getLineHeight(line);
878
+ }
879
+
880
+ ctx.y = savedY; // restore for next cell
881
+ }
882
+ }
883
+
884
+ ctx.y -= rowHeight;
885
+ }
886
+
887
+ ctx.y -= PARAGRAPH_SPACING;
888
+ }
889
+
890
+ // ---- Thematic Break ----
891
+
892
+ function renderThematicBreak(ctx: ExportContext, extraIndent: number): void {
893
+ ctx.y -= PARAGRAPH_SPACING;
894
+ ensureSpace(ctx, 10);
895
+
896
+ const x0 = ctx.margin + extraIndent;
897
+ const x1 = ctx.margin + ctx.contentWidth;
898
+
899
+ ctx.page.drawLine({
900
+ start: { x: x0, y: ctx.y },
901
+ end: { x: x1, y: ctx.y },
902
+ thickness: 1,
903
+ color: rgb(COLOR_THEMATIC_BREAK.r, COLOR_THEMATIC_BREAK.g, COLOR_THEMATIC_BREAK.b),
904
+ });
905
+
906
+ ctx.y -= PARAGRAPH_SPACING;
907
+ }
908
+
909
+ // ---- HTML Block ----
910
+
911
+ function renderHtmlBlock(node: MarkdownHtmlBlock, ctx: ExportContext, extraIndent: number): void {
912
+ if (!node.rawHtml) return;
913
+ // Render raw HTML as monospace text (best effort)
914
+ const lines = node.rawHtml.split('\n');
915
+ const x0 = ctx.margin + extraIndent;
916
+ for (const line of lines) {
917
+ if (line.trim().length === 0) continue;
918
+ const lineH = CODE_FONT_SIZE * LINE_HEIGHT_FACTOR;
919
+ ensureSpace(ctx, lineH);
920
+ ctx.page.drawText(line, {
921
+ x: x0,
922
+ y: ctx.y - CODE_FONT_SIZE,
923
+ size: CODE_FONT_SIZE,
924
+ font: ctx.fonts.mono,
925
+ color: rgb(COLOR_CODE_TEXT.r, COLOR_CODE_TEXT.g, COLOR_CODE_TEXT.b),
926
+ });
927
+ ctx.y -= lineH;
928
+ }
929
+ ctx.y -= PARAGRAPH_SPACING;
930
+ }
931
+
932
+ // ---- Math Block ----
933
+
934
+ function renderMathBlock(node: MarkdownMathBlock, ctx: ExportContext, extraIndent: number): void {
935
+ // Render LaTeX source in monospace as fallback
936
+ const lines = node.value.split('\n');
937
+ const x0 = ctx.margin + extraIndent;
938
+ for (const line of lines) {
939
+ const lineH = CODE_FONT_SIZE * LINE_HEIGHT_FACTOR;
940
+ ensureSpace(ctx, lineH);
941
+ if (line.length > 0) {
942
+ ctx.page.drawText(line, {
943
+ x: x0,
944
+ y: ctx.y - CODE_FONT_SIZE,
945
+ size: CODE_FONT_SIZE,
946
+ font: ctx.fonts.mono,
947
+ color: rgb(COLOR_CODE_TEXT.r, COLOR_CODE_TEXT.g, COLOR_CODE_TEXT.b),
948
+ });
949
+ }
950
+ ctx.y -= lineH;
951
+ }
952
+ ctx.y -= PARAGRAPH_SPACING;
953
+ }
954
+
955
+ // ---- Footnote Definition ----
956
+
957
+ function renderFootnoteDefinition(
958
+ node: MarkdownFootnoteDefinition,
959
+ ctx: ExportContext,
960
+ extraIndent: number,
961
+ ): void {
962
+ // Draw footnote identifier
963
+ const label = `[${node.identifier}]`;
964
+ const lineH = ctx.fontSize * LINE_HEIGHT_FACTOR;
965
+ ensureSpace(ctx, lineH);
966
+
967
+ ctx.page.drawText(label, {
968
+ x: ctx.margin + extraIndent,
969
+ y: ctx.y - ctx.fontSize * 0.75,
970
+ size: ctx.fontSize * 0.75,
971
+ font: ctx.fonts.bold,
972
+ color: rgb(COLOR_LINK.r, COLOR_LINK.g, COLOR_LINK.b),
973
+ });
974
+
975
+ // Render children indented
976
+ renderBlocks(node.children, ctx, extraIndent + LIST_INDENT);
977
+ }
978
+
979
+ // ---- Fallback ----
980
+
981
+ function renderFallbackBlock(
982
+ node: MarkdownBlockNode,
983
+ ctx: ExportContext,
984
+ extraIndent: number,
985
+ ): void {
986
+ const fallback = node as unknown as {
987
+ children?: (MarkdownBlockNode | MarkdownInlineNode)[];
988
+ value?: string;
989
+ };
990
+ if (fallback.children && Array.isArray(fallback.children)) {
991
+ // Could be block children or inline children
992
+ if (fallback.children.length > 0 && typeof fallback.children[0]?.type === 'string') {
993
+ const firstType = fallback.children[0].type;
994
+ // Heuristic: if first child looks like an inline node, wrap as paragraph
995
+ const inlineTypes = new Set([
996
+ 'text',
997
+ 'strong',
998
+ 'emphasis',
999
+ 'delete',
1000
+ 'inlineCode',
1001
+ 'link',
1002
+ 'image',
1003
+ 'break',
1004
+ ]);
1005
+ if (inlineTypes.has(firstType)) {
1006
+ const spans = flattenInlines(fallback.children as MarkdownInlineNode[], ctx, {
1007
+ bold: false,
1008
+ italic: false,
1009
+ code: false,
1010
+ });
1011
+ drawSpans(spans, ctx, ctx.contentWidth - extraIndent, ctx.margin + extraIndent);
1012
+ ctx.y -= PARAGRAPH_SPACING;
1013
+ return;
1014
+ }
1015
+ }
1016
+ renderBlocks(fallback.children as MarkdownBlockNode[], ctx, extraIndent);
1017
+ } else if (fallback.value && typeof fallback.value === 'string') {
1018
+ const lineH = ctx.fontSize * LINE_HEIGHT_FACTOR;
1019
+ ensureSpace(ctx, lineH);
1020
+ ctx.page.drawText(fallback.value, {
1021
+ x: ctx.margin + extraIndent,
1022
+ y: ctx.y - ctx.fontSize,
1023
+ size: ctx.fontSize,
1024
+ font: ctx.fonts.regular,
1025
+ color: rgb(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b),
1026
+ });
1027
+ ctx.y -= lineH + PARAGRAPH_SPACING;
1028
+ }
1029
+ }