@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.
- package/dist/chunk-532L4D5D.js +21 -0
- package/dist/chunk-532L4D5D.js.map +1 -0
- package/dist/chunk-67KIJHV2.js +21 -0
- package/dist/chunk-67KIJHV2.js.map +1 -0
- package/dist/chunk-KAK4V57E.js +387 -0
- package/dist/chunk-KAK4V57E.js.map +1 -0
- package/dist/chunk-MQHCXI56.js +1035 -0
- package/dist/chunk-MQHCXI56.js.map +1 -0
- package/dist/chunk-TBPD5PCU.js +1136 -0
- package/dist/chunk-TBPD5PCU.js.map +1 -0
- package/dist/chunk-ULLIPBEJ.js +271 -0
- package/dist/chunk-ULLIPBEJ.js.map +1 -0
- package/dist/docx/index.d.ts +108 -0
- package/dist/docx/index.js +14 -0
- package/dist/docx/index.js.map +1 -0
- package/dist/html/index.d.ts +166 -0
- package/dist/html/index.js +17 -0
- package/dist/html/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/ooxml/index.d.ts +351 -0
- package/dist/ooxml/index.js +99 -0
- package/dist/ooxml/index.js.map +1 -0
- package/dist/pdf/index.d.ts +130 -0
- package/dist/pdf/index.js +15 -0
- package/dist/pdf/index.js.map +1 -0
- package/dist/pptx/index.d.ts +58 -0
- package/dist/pptx/index.js +13 -0
- package/dist/pptx/index.js.map +1 -0
- package/dist/xlsx/index.d.ts +58 -0
- package/dist/xlsx/index.js +13 -0
- package/dist/xlsx/index.js.map +1 -0
- package/package.json +85 -0
- package/src/__tests__/docxExport.test.ts +457 -0
- package/src/__tests__/docxImport.test.ts +410 -0
- package/src/__tests__/html.test.ts +295 -0
- package/src/__tests__/ooxml.test.ts +197 -0
- package/src/__tests__/pdfExport.test.ts +322 -0
- package/src/__tests__/pdfImport.test.ts +290 -0
- package/src/__tests__/roundTrip.test.ts +201 -0
- package/src/docx/export.ts +978 -0
- package/src/docx/import.ts +909 -0
- package/src/docx/index.ts +26 -0
- package/src/docx/styles.ts +145 -0
- package/src/html/htmlTemplate.ts +307 -0
- package/src/html/imageUtils.ts +66 -0
- package/src/html/index.ts +168 -0
- package/src/index.ts +51 -0
- package/src/ooxml/index.ts +87 -0
- package/src/ooxml/namespaces.ts +160 -0
- package/src/ooxml/reader.ts +218 -0
- package/src/ooxml/types.ts +104 -0
- package/src/ooxml/writer.ts +321 -0
- package/src/ooxml/xmlUtils.ts +123 -0
- package/src/pdf/export.ts +1029 -0
- package/src/pdf/import.ts +835 -0
- package/src/pdf/index.ts +29 -0
- package/src/pdf/styles.ts +180 -0
- package/src/pptx/index.ts +78 -0
- 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
|
+
}
|