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