freemd 0.0.0-stage → 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.
@@ -0,0 +1,541 @@
1
+ import PDFDocument from 'pdfkit';
2
+ import { highlightSegments } from './highlight.js';
3
+
4
+ // Formatos raster que pdfkit puede incrustar; el resto se exporta como marcador visible.
5
+ const PDF_TYPES = new Set(['png', 'jpg']);
6
+
7
+ const COLORS = {
8
+ text: '#24292f',
9
+ muted: '#6a737d',
10
+ codeBg: '#f2f2f2',
11
+ link: '#0969da',
12
+ border: '#d0d7de'
13
+ };
14
+
15
+ function findClose(tokens, start, openType, closeType) {
16
+ let depth = 0;
17
+ for (let i = start; i < tokens.length; i++) {
18
+ if (tokens[i].type === openType) depth++;
19
+ else if (tokens[i].type === closeType) {
20
+ depth--;
21
+ if (depth === 0) return i;
22
+ }
23
+ }
24
+ return tokens.length - 1;
25
+ }
26
+
27
+ function roman(number) {
28
+ const pairs = [
29
+ [10, 'X'],
30
+ [9, 'IX'],
31
+ [5, 'V'],
32
+ [4, 'IV'],
33
+ [1, 'I']
34
+ ];
35
+ let out = '';
36
+ for (const [value, symbol] of pairs) {
37
+ while (number >= value) {
38
+ out += symbol;
39
+ number -= value;
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function orderedMarker(number, level) {
46
+ const kind = level % 3;
47
+ if (kind === 0) return `${number}.`;
48
+ if (kind === 1) {
49
+ return number <= 26 ? `${String.fromCharCode(96 + number)}.` : `${number}.`;
50
+ }
51
+ return `${roman(number).toLowerCase()}.`;
52
+ }
53
+
54
+ class PdfRenderer {
55
+ constructor(doc, ctx) {
56
+ this.doc = doc;
57
+ this.images = ctx.images;
58
+ this.margins = {
59
+ left: doc.page.margins.left,
60
+ right: doc.page.margins.right,
61
+ top: doc.page.margins.top,
62
+ bottom: doc.page.margins.bottom
63
+ };
64
+ this.width = doc.page.width - this.margins.left - this.margins.right;
65
+ this.bold = false;
66
+ this.italic = false;
67
+ this.href = null;
68
+ this.quoteDepth = 0;
69
+ this.listLevel = 0;
70
+ this.listCtx = null;
71
+ this.pageBreaks = 0;
72
+ }
73
+
74
+ get bottom() {
75
+ return this.doc.page.height - this.margins.bottom;
76
+ }
77
+
78
+ ensureSpace(height) {
79
+ if (this.doc.y + height > this.bottom) {
80
+ this.doc.addPage();
81
+ this.pageBreaks++;
82
+ }
83
+ }
84
+
85
+ fontFor() {
86
+ const italic = this.italic || this.quoteDepth > 0;
87
+ if (this.bold && italic) return 'Helvetica-BoldOblique';
88
+ if (this.bold) return 'Helvetica-Bold';
89
+ if (italic) return 'Helvetica-Oblique';
90
+ return 'Helvetica';
91
+ }
92
+
93
+ textColor() {
94
+ return this.quoteDepth > 0 ? COLORS.muted : COLORS.text;
95
+ }
96
+
97
+ makeRun(text, size, override = {}) {
98
+ return {
99
+ text,
100
+ opts: {
101
+ font: override.font ?? this.fontFor(),
102
+ size,
103
+ color: this.href ? COLORS.link : this.textColor(),
104
+ underline: this.href ? true : undefined,
105
+ link: this.href ?? undefined
106
+ }
107
+ };
108
+ }
109
+
110
+ collectRuns(children, size) {
111
+ const runs = [];
112
+ const images = [];
113
+ for (const child of children) {
114
+ switch (child.type) {
115
+ case 'text':
116
+ if (child.content) runs.push(this.makeRun(child.content, size));
117
+ break;
118
+ case 'code_inline':
119
+ runs.push(this.makeRun(child.content, size, { font: 'Courier' }));
120
+ break;
121
+ case 'strong_open':
122
+ this.bold = true;
123
+ break;
124
+ case 'strong_close':
125
+ this.bold = false;
126
+ break;
127
+ case 'em_open':
128
+ this.italic = true;
129
+ break;
130
+ case 'em_close':
131
+ this.italic = false;
132
+ break;
133
+ case 'link_open':
134
+ this.href = child.attrGet('href');
135
+ break;
136
+ case 'link_close':
137
+ this.href = null;
138
+ break;
139
+ case 'image':
140
+ images.push(child);
141
+ break;
142
+ case 'softbreak':
143
+ case 'hardbreak':
144
+ runs.push({ text: '\n', opts: { font: this.fontFor(), size, color: this.textColor() } });
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+ return { runs, images };
151
+ }
152
+
153
+ flushRuns(runs, base = {}) {
154
+ const doc = this.doc;
155
+ if (!runs.length) return;
156
+ runs.forEach((run, index) => {
157
+ const isLast = index === runs.length - 1;
158
+ // pdfkit no acepta color como opción de .text(): hay que fijar fillColor por run.
159
+ doc.font(run.opts.font).fontSize(run.opts.size).fillColor(run.opts.color);
160
+ doc.text(run.text || ' ', {
161
+ ...base,
162
+ underline: run.opts.underline,
163
+ link: run.opts.link,
164
+ continued: !isLast,
165
+ lineGap: 3
166
+ });
167
+ });
168
+ }
169
+
170
+ renderParagraphContent(children, listInfo = null) {
171
+ const doc = this.doc;
172
+ const savedX = doc.x;
173
+ let width;
174
+ if (listInfo) {
175
+ doc.x = listInfo.x;
176
+ width = listInfo.width;
177
+ } else if (this.quoteDepth > 0) {
178
+ doc.x = this.margins.left + this.quoteDepth * 14;
179
+ width = this.width - this.quoteDepth * 14;
180
+ } else {
181
+ this.ensureSpace(24);
182
+ }
183
+ const { runs, images } = this.collectRuns(children, 11);
184
+ this.flushRuns(runs, width ? { width } : {});
185
+ doc.x = savedX;
186
+ images.forEach((image) => this.renderImage(image));
187
+ doc.moveDown(listInfo ? 0.2 : 0.5);
188
+ }
189
+
190
+ renderHeading(level, children) {
191
+ const sizes = { 1: 24, 2: 18.5, 3: 15, 4: 12.5, 5: 11.5, 6: 11 };
192
+ const doc = this.doc;
193
+ doc.moveDown(level <= 2 ? 1.0 : 0.7);
194
+ this.ensureSpace(30);
195
+ const savedBold = this.bold;
196
+ this.bold = true;
197
+ const { runs, images } = this.collectRuns(children, sizes[level] ?? 11);
198
+ this.flushRuns(runs);
199
+ this.bold = savedBold;
200
+ if (level <= 2) {
201
+ const y = doc.y + 5;
202
+ doc
203
+ .moveTo(this.margins.left, y)
204
+ .lineTo(this.margins.left + this.width, y)
205
+ .lineWidth(0.8)
206
+ .strokeColor(COLORS.border)
207
+ .stroke();
208
+ doc.y = y + 4;
209
+ }
210
+ doc.moveDown(0.5);
211
+ images.forEach((image) => this.renderImage(image));
212
+ }
213
+
214
+ renderCode(content, info) {
215
+ const doc = this.doc;
216
+ const fontSize = 9.5;
217
+ const lineHeight = 14;
218
+ const padX = 10;
219
+ const padY = 8;
220
+ const maxChars = Math.floor((this.width - padX * 2) / (fontSize * 0.602));
221
+ const segments = highlightSegments(content.replace(/\n$/, ''), info);
222
+ const lines = [];
223
+ let current = [];
224
+ let count = 0;
225
+ const pushLine = () => {
226
+ lines.push(current);
227
+ current = [];
228
+ count = 0;
229
+ };
230
+ for (const segment of segments) {
231
+ const pieces = segment.text.split('\n');
232
+ pieces.forEach((piece, index) => {
233
+ if (index > 0) pushLine();
234
+ while (piece.length > 0) {
235
+ if (count >= maxChars) pushLine();
236
+ const take = Math.min(maxChars - count, piece.length);
237
+ current.push({ text: piece.slice(0, take), color: segment.color });
238
+ count += take;
239
+ piece = piece.slice(take);
240
+ }
241
+ });
242
+ }
243
+ if (current.length || !lines.length) pushLine();
244
+
245
+ doc.moveDown(0.4);
246
+ let y = doc.y;
247
+ let chunkTop = null;
248
+ let chunkBottom = null;
249
+ // Cierra el trozo visible del bloque: relleno inferior + borde del recuadro.
250
+ const closeChunk = () => {
251
+ if (chunkTop === null) return;
252
+ doc.rect(this.margins.left - padX, chunkBottom, this.width + padX * 2, padY).fill(COLORS.codeBg);
253
+ doc
254
+ .rect(this.margins.left - padX, chunkTop - padY, this.width + padX * 2, chunkBottom - chunkTop + padY * 2)
255
+ .lineWidth(0.75)
256
+ .strokeColor(COLORS.border)
257
+ .stroke();
258
+ chunkTop = null;
259
+ };
260
+ for (const segs of lines) {
261
+ if (y + lineHeight + padY > this.bottom) {
262
+ closeChunk();
263
+ doc.addPage();
264
+ this.pageBreaks++;
265
+ y = doc.y;
266
+ }
267
+ if (chunkTop === null) {
268
+ chunkTop = y;
269
+ doc.rect(this.margins.left - padX, y - padY, this.width + padX * 2, padY).fill(COLORS.codeBg);
270
+ }
271
+ doc.rect(this.margins.left - padX, y, this.width + padX * 2, lineHeight).fill(COLORS.codeBg);
272
+ let x = this.margins.left;
273
+ for (const seg of segs) {
274
+ doc
275
+ .font('Courier')
276
+ .fontSize(fontSize)
277
+ .fillColor(seg.color ? `#${seg.color}` : COLORS.text);
278
+ doc.text(seg.text, x, y + 4, { lineBreak: false });
279
+ x += doc.widthOfString(seg.text);
280
+ }
281
+ y += lineHeight;
282
+ chunkBottom = y;
283
+ }
284
+ closeChunk();
285
+ doc.x = this.margins.left;
286
+ doc.y = chunkBottom + padY + 16;
287
+ }
288
+
289
+ renderQuote(inner) {
290
+ const doc = this.doc;
291
+ const startY = doc.y;
292
+ const startPage = this.pageBreaks;
293
+ this.quoteDepth++;
294
+ doc.x = this.margins.left + this.quoteDepth * 14;
295
+ this.renderBlocks(inner);
296
+ this.quoteDepth--;
297
+ doc.x = this.margins.left;
298
+ if (startPage === this.pageBreaks && doc.y > startY) {
299
+ const x = this.margins.left + this.quoteDepth * 14 + 3;
300
+ doc
301
+ .moveTo(x, startY)
302
+ .lineTo(x, doc.y - 4)
303
+ .lineWidth(2)
304
+ .strokeColor(COLORS.border)
305
+ .stroke();
306
+ }
307
+ doc.moveDown(0.4);
308
+ }
309
+
310
+ renderList(tokens, ordered, openToken) {
311
+ const doc = this.doc;
312
+ this.listLevel++;
313
+ const indent = (this.listLevel - 1) * 18;
314
+ const baseX = this.margins.left + indent;
315
+ let counter = Number(openToken.attrGet('start') ?? 1) || 1;
316
+ doc.moveDown(0.3);
317
+ let i = 0;
318
+ while (i < tokens.length) {
319
+ if (tokens[i].type !== 'list_item_open') {
320
+ i++;
321
+ continue;
322
+ }
323
+ const close = findClose(tokens, i, 'list_item_open', 'list_item_close');
324
+ const inner = tokens.slice(i + 1, close);
325
+ const marker = ordered ? orderedMarker(counter, this.listLevel - 1) : '•';
326
+ if (ordered) counter++;
327
+ const markerY = doc.y;
328
+ doc
329
+ .font('Helvetica')
330
+ .fontSize(11)
331
+ .fillColor(COLORS.text)
332
+ .text(marker, baseX, markerY, { lineBreak: false });
333
+ doc.y = markerY;
334
+ this.listCtx = { x: baseX + 14, width: this.width - indent - 14 };
335
+ this.renderBlocks(inner);
336
+ this.listCtx = null;
337
+ i = close + 1;
338
+ }
339
+ this.listLevel--;
340
+ doc.x = this.margins.left;
341
+ doc.moveDown(0.4);
342
+ }
343
+
344
+ renderHr() {
345
+ const doc = this.doc;
346
+ doc.moveDown(0.5);
347
+ const y = doc.y;
348
+ doc
349
+ .moveTo(this.margins.left, y)
350
+ .lineTo(this.margins.left + this.width, y)
351
+ .lineWidth(1)
352
+ .strokeColor(COLORS.border)
353
+ .stroke();
354
+ doc.y = y + 14;
355
+ }
356
+
357
+ renderImage(token) {
358
+ const doc = this.doc;
359
+ const src = token.attrGet('src') ?? '';
360
+ const alt = token.content || 'imagen';
361
+ const loaded = this.images.get(src);
362
+ if (!loaded || !PDF_TYPES.has(loaded.type)) {
363
+ doc.moveDown(0.3);
364
+ doc
365
+ .font('Helvetica-Oblique')
366
+ .fontSize(10)
367
+ .fillColor(COLORS.muted)
368
+ .text(`[imagen no soportada: ${alt}]`, { align: 'center' });
369
+ doc.moveDown(0.5);
370
+ return;
371
+ }
372
+ const maxHeight = this.bottom - this.margins.top;
373
+ const scale = Math.min(this.width / loaded.width, maxHeight / loaded.height, 1);
374
+ const drawWidth = loaded.width * scale;
375
+ const drawHeight = loaded.height * scale;
376
+ doc.moveDown(0.3);
377
+ this.ensureSpace(drawHeight + 14);
378
+ const x = this.margins.left + (this.width - drawWidth) / 2;
379
+ doc.image(loaded.buffer, x, doc.y, { width: drawWidth, height: drawHeight });
380
+ doc.y += drawHeight + 12;
381
+ }
382
+
383
+ renderTable(tokens) {
384
+ const doc = this.doc;
385
+ const rows = [];
386
+ let inHead = false;
387
+ let cells = null;
388
+ for (let i = 0; i < tokens.length; i++) {
389
+ const token = tokens[i];
390
+ switch (token.type) {
391
+ case 'thead_open':
392
+ inHead = true;
393
+ break;
394
+ case 'thead_close':
395
+ inHead = false;
396
+ break;
397
+ case 'tr_open':
398
+ cells = [];
399
+ break;
400
+ case 'tr_close':
401
+ rows.push({ cells, header: inHead });
402
+ cells = null;
403
+ break;
404
+ case 'th_open':
405
+ case 'td_open': {
406
+ const inline = tokens[i + 1];
407
+ cells.push(
408
+ (inline?.children ?? [])
409
+ .map((child) =>
410
+ child.type === 'text' || child.type === 'code_inline' ? child.content : ''
411
+ )
412
+ .join('')
413
+ .trim()
414
+ );
415
+ break;
416
+ }
417
+ default:
418
+ break;
419
+ }
420
+ }
421
+ if (!rows.length) return;
422
+ const cols = Math.max(...rows.map((row) => row.cells.length));
423
+ const colWidth = this.width / cols;
424
+ const pad = 5;
425
+ doc.font('Helvetica').fontSize(10);
426
+ const heights = rows.map((row) => {
427
+ const contentHeight = row.cells.length
428
+ ? Math.max(...row.cells.map((cell) => doc.heightOfString(cell || ' ', { width: colWidth - pad * 2 })))
429
+ : 14;
430
+ return contentHeight + pad;
431
+ });
432
+ doc.moveDown(0.3);
433
+ const startY = doc.y;
434
+ let y = startY;
435
+ rows.forEach((row, index) => {
436
+ const rowHeight = heights[index];
437
+ if (y + rowHeight > this.bottom) {
438
+ doc.addPage();
439
+ this.pageBreaks++;
440
+ y = doc.y;
441
+ }
442
+ if (row.header) {
443
+ doc.rect(this.margins.left, y, this.width, rowHeight).fill(COLORS.codeBg);
444
+ }
445
+ doc.fillColor(COLORS.text);
446
+ row.cells.forEach((cell, cellIndex) => {
447
+ doc
448
+ .font(row.header ? 'Helvetica-Bold' : 'Helvetica')
449
+ .fontSize(10)
450
+ .text(cell || ' ', this.margins.left + cellIndex * colWidth + pad, y + pad / 2, {
451
+ width: colWidth - pad * 2
452
+ });
453
+ });
454
+ y += rowHeight;
455
+ doc
456
+ .moveTo(this.margins.left, y)
457
+ .lineTo(this.margins.left + this.width, y)
458
+ .lineWidth(0.75)
459
+ .strokeColor(COLORS.border)
460
+ .stroke();
461
+ });
462
+ for (let c = 0; c <= cols; c++) {
463
+ const x = this.margins.left + c * colWidth;
464
+ doc.moveTo(x, startY).lineTo(x, y).lineWidth(0.75).strokeColor(COLORS.border).stroke();
465
+ }
466
+ doc.y = y + 10;
467
+ }
468
+
469
+ renderBlocks(tokens) {
470
+ let i = 0;
471
+ while (i < tokens.length) {
472
+ const token = tokens[i];
473
+ switch (token.type) {
474
+ case 'inline':
475
+ i++;
476
+ this.renderParagraphContent(token.children ?? [], this.listCtx);
477
+ break;
478
+ case 'heading_open': {
479
+ const level = Number(token.tag.slice(1));
480
+ const inline = tokens[i + 1];
481
+ i += 3;
482
+ this.renderHeading(level, inline?.children ?? []);
483
+ break;
484
+ }
485
+ case 'paragraph_open': {
486
+ const inline = tokens[i + 1];
487
+ i += 3;
488
+ this.renderParagraphContent(inline?.children ?? [], this.listCtx);
489
+ break;
490
+ }
491
+ case 'fence':
492
+ case 'code_block':
493
+ i++;
494
+ this.renderCode(token.content, token.info);
495
+ break;
496
+ case 'blockquote_open': {
497
+ const close = findClose(tokens, i, 'blockquote_open', 'blockquote_close');
498
+ this.renderQuote(tokens.slice(i + 1, close));
499
+ i = close + 1;
500
+ break;
501
+ }
502
+ case 'bullet_list_open':
503
+ case 'ordered_list_open': {
504
+ const ordered = token.type === 'ordered_list_open';
505
+ const close = findClose(tokens, i, token.type, `${token.type.slice(0, -5)}_close`);
506
+ this.renderList(tokens.slice(i + 1, close), ordered, token);
507
+ i = close + 1;
508
+ break;
509
+ }
510
+ case 'hr':
511
+ i++;
512
+ this.renderHr();
513
+ break;
514
+ case 'table_open': {
515
+ const close = findClose(tokens, i, 'table_open', 'table_close');
516
+ this.renderTable(tokens.slice(i + 1, close));
517
+ i = close + 1;
518
+ break;
519
+ }
520
+ default:
521
+ i++;
522
+ }
523
+ }
524
+ }
525
+ }
526
+
527
+ export function markdownToPdf(tokens, ctx) {
528
+ return new Promise((resolve, reject) => {
529
+ const doc = new PDFDocument({
530
+ size: 'A4',
531
+ margins: { top: 60, bottom: 60, left: 60, right: 60 },
532
+ info: { Title: ctx.title, Creator: 'openmd' }
533
+ });
534
+ const chunks = [];
535
+ doc.on('data', (chunk) => chunks.push(chunk));
536
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
537
+ doc.on('error', reject);
538
+ new PdfRenderer(doc, ctx).renderBlocks(tokens);
539
+ doc.end();
540
+ });
541
+ }