@speajus/markdown-to-pdf 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -15
- package/dist/browser-image-renderer.d.ts +12 -0
- package/dist/browser-image-renderer.js +202 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +19 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +40 -0
- package/dist/color-emoji.d.ts +43 -0
- package/dist/color-emoji.js +142 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.js +4 -0
- package/dist/emoji.d.ts +35 -0
- package/dist/emoji.js +137 -0
- package/dist/highlight.prism.d.ts +76 -0
- package/dist/highlight.prism.js +227 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +20 -0
- package/dist/node-color-emoji.d.ts +15 -0
- package/dist/node-color-emoji.js +57 -0
- package/dist/node-image-renderer.d.ts +10 -0
- package/dist/node-image-renderer.js +81 -0
- package/dist/renderer.d.ts +2 -0
- package/dist/renderer.js +718 -0
- package/dist/styles.d.ts +5 -0
- package/dist/styles.js +92 -0
- package/dist/themes/index.d.ts +12 -0
- package/dist/themes/index.js +158 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.js +2 -0
- package/package.json +12 -9
- /package/dist/{src/fonts → fonts}/NotoEmoji-Regular.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-Bold.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-BoldItalic.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-Italic.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-Regular.ttf +0 -0
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderMarkdownToPdf = renderMarkdownToPdf;
|
|
7
|
+
const styles_js_1 = require("./styles.js");
|
|
8
|
+
const pdfkit_1 = __importDefault(require("pdfkit"));
|
|
9
|
+
const marked_1 = require("marked");
|
|
10
|
+
const stream_1 = require("stream");
|
|
11
|
+
const defaults_js_1 = require("./defaults.js");
|
|
12
|
+
const highlight_prism_js_1 = require("./highlight.prism.js");
|
|
13
|
+
const emoji_js_1 = require("./emoji.js");
|
|
14
|
+
const color_emoji_js_1 = require("./color-emoji.js");
|
|
15
|
+
/** Name used to register the emoji font with PDFKit */
|
|
16
|
+
const EMOJI_FONT_NAME = 'NotoEmoji';
|
|
17
|
+
async function renderMarkdownToPdf(markdown, options) {
|
|
18
|
+
const theme = options?.theme ?? styles_js_1.defaultTheme;
|
|
19
|
+
const layout = options?.pageLayout ?? styles_js_1.defaultPageLayout;
|
|
20
|
+
const basePath = options?.basePath ?? '';
|
|
21
|
+
const syntaxHighlight = options?.syntaxHighlight !== false;
|
|
22
|
+
if (syntaxHighlight) {
|
|
23
|
+
(0, highlight_prism_js_1.loadHighlightLanguages)(options?.languages);
|
|
24
|
+
}
|
|
25
|
+
const emojiFontOpt = options?.emojiFont ?? true;
|
|
26
|
+
// Use provided image renderer or create default Node.js renderer
|
|
27
|
+
const imageRenderer = options?.renderImage ?? defaults_js_1.DEFAULTS.renderImage(basePath);
|
|
28
|
+
const { margins } = layout;
|
|
29
|
+
const doc = new pdfkit_1.default({ size: layout.pageSize, margins });
|
|
30
|
+
const stream = new stream_1.PassThrough();
|
|
31
|
+
const chunks = [];
|
|
32
|
+
doc.pipe(stream);
|
|
33
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
34
|
+
// ── Emoji font registration ───────────────────────────────────────────────
|
|
35
|
+
// The font registration block uses dynamic require() for `path` and `fs` so
|
|
36
|
+
// that the renderer module can also be imported in browser environments where
|
|
37
|
+
// those Node.js built-ins are unavailable. If they aren't available the
|
|
38
|
+
// try/catch simply falls through and emoji support is disabled (unless the
|
|
39
|
+
// caller passes a Buffer directly via the `emojiFont` option).
|
|
40
|
+
let emojiEnabled = false;
|
|
41
|
+
if (emojiFontOpt !== false) {
|
|
42
|
+
try {
|
|
43
|
+
if (Buffer.isBuffer(emojiFontOpt)) {
|
|
44
|
+
// Raw font data — works in both Node.js and browser environments.
|
|
45
|
+
doc.registerFont(EMOJI_FONT_NAME, emojiFontOpt);
|
|
46
|
+
emojiEnabled = true;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// Resolve a file path — requires Node.js built-ins.
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
51
|
+
const nodePath = require('path');
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
53
|
+
const nodeFs = require('fs');
|
|
54
|
+
const fontPath = typeof emojiFontOpt === 'string'
|
|
55
|
+
? emojiFontOpt
|
|
56
|
+
: nodePath.join(__dirname, 'fonts', 'NotoEmoji-Regular.ttf');
|
|
57
|
+
if (nodeFs.existsSync(fontPath)) {
|
|
58
|
+
doc.registerFont(EMOJI_FONT_NAME, fontPath);
|
|
59
|
+
emojiEnabled = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Font registration failed or Node.js APIs not available (browser) —
|
|
65
|
+
// fall through without emoji support.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Color emoji pre-render ─────────────────────────────────────────────
|
|
69
|
+
// When a colorEmoji renderer is provided, pre-scan the entire markdown for
|
|
70
|
+
// every unique emoji and convert them all to PNG buffers up-front. This
|
|
71
|
+
// lets `renderTextWithEmoji` remain synchronous during page rendering.
|
|
72
|
+
let emojiImageCache;
|
|
73
|
+
const colorEmojiEnabled = !!options?.colorEmoji;
|
|
74
|
+
if (options?.colorEmoji) {
|
|
75
|
+
emojiImageCache = await (0, color_emoji_js_1.preRenderEmoji)(markdown, options.colorEmoji, (0, emoji_js_1.getEmojiRegex)());
|
|
76
|
+
}
|
|
77
|
+
const tokens = marked_1.marked.lexer(markdown);
|
|
78
|
+
const contentWidth = doc.page.width - margins.left - margins.right;
|
|
79
|
+
// ── Table cell context ──────────────────────────────────────────────────
|
|
80
|
+
// When rendering inline tokens inside a table cell, the first text output
|
|
81
|
+
// must be positioned at the cell's (x, y) with the cell's width/align.
|
|
82
|
+
// Subsequent text outputs in the same cell use PDFKit's continued-flow.
|
|
83
|
+
let cellCtx = null;
|
|
84
|
+
// ── Heading context ───────────────────────────────────────────────────
|
|
85
|
+
// When rendering inline tokens inside a heading, font helpers use the
|
|
86
|
+
// heading's style (font, fontSize, color) instead of the body style.
|
|
87
|
+
let headingCtx = null;
|
|
88
|
+
function ensureSpace(needed) {
|
|
89
|
+
if (doc.y + needed > doc.page.height - margins.bottom) {
|
|
90
|
+
doc.addPage();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Derive the italic variant of a PDFKit built-in font name. */
|
|
94
|
+
function italicVariant(font) {
|
|
95
|
+
if (font.startsWith('Times'))
|
|
96
|
+
return font.replace(/-Bold$/, '-BoldItalic').replace(/^Times-Roman$/, 'Times-Italic');
|
|
97
|
+
// Helvetica / Courier families use "Oblique"
|
|
98
|
+
if (font.endsWith('-Bold'))
|
|
99
|
+
return font + 'Oblique';
|
|
100
|
+
return font + '-Oblique';
|
|
101
|
+
}
|
|
102
|
+
function applyBodyFont(bold, italic) {
|
|
103
|
+
if (headingCtx) {
|
|
104
|
+
// Inside a heading — use heading style as the base.
|
|
105
|
+
let font = headingCtx.font;
|
|
106
|
+
if (italic)
|
|
107
|
+
font = italicVariant(font);
|
|
108
|
+
doc.font(font).fontSize(headingCtx.fontSize).fillColor(headingCtx.color);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let font = theme.body.font;
|
|
112
|
+
if (bold && italic)
|
|
113
|
+
font = 'Helvetica-BoldOblique';
|
|
114
|
+
else if (bold)
|
|
115
|
+
font = 'Helvetica-Bold';
|
|
116
|
+
else if (italic)
|
|
117
|
+
font = 'Helvetica-Oblique';
|
|
118
|
+
doc.font(font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
119
|
+
}
|
|
120
|
+
function resetBodyFont() {
|
|
121
|
+
if (headingCtx) {
|
|
122
|
+
doc.font(headingCtx.font).fontSize(headingCtx.fontSize).fillColor(headingCtx.color);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
doc.font(theme.body.font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Render a text string switching to the emoji font for emoji characters.
|
|
129
|
+
*
|
|
130
|
+
* Preserves the caller's current font / fontSize / fillColor for the
|
|
131
|
+
* non-emoji portions. The `continued` flag behaves exactly like the
|
|
132
|
+
* native PDFKit option — pass `true` to keep the line open.
|
|
133
|
+
*
|
|
134
|
+
* When `colorEmojiEnabled` is true and `emojiImageCache` is populated,
|
|
135
|
+
* emoji characters are rendered as inline PNG images instead of font
|
|
136
|
+
* glyphs, with manual (x, y) positioning to keep them in the text flow.
|
|
137
|
+
*
|
|
138
|
+
* Supports both `renderTextWithEmoji(text, opts?)` and the positioned
|
|
139
|
+
* form `renderTextWithEmoji(text, x, y, opts?)` used by table cells.
|
|
140
|
+
*/
|
|
141
|
+
function renderTextWithEmoji(text, xOrOpts, yOrUndefined, posOpts) {
|
|
142
|
+
// Normalise the two call signatures into a single (opts, firstX, firstY).
|
|
143
|
+
let opts;
|
|
144
|
+
let firstX;
|
|
145
|
+
let firstY;
|
|
146
|
+
if (typeof xOrOpts === 'number') {
|
|
147
|
+
firstX = xOrOpts;
|
|
148
|
+
firstY = yOrUndefined;
|
|
149
|
+
opts = posOpts ?? {};
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
opts = xOrOpts ?? {};
|
|
153
|
+
}
|
|
154
|
+
// If inside a table cell and this is the first text output, apply cell positioning.
|
|
155
|
+
if (cellCtx && !cellCtx.used && firstX === undefined) {
|
|
156
|
+
firstX = cellCtx.x;
|
|
157
|
+
firstY = cellCtx.y;
|
|
158
|
+
opts = { ...opts, width: cellCtx.width, align: cellCtx.align };
|
|
159
|
+
cellCtx.used = true;
|
|
160
|
+
}
|
|
161
|
+
const hasEmoji = (0, emoji_js_1.containsEmoji)(text);
|
|
162
|
+
// ── Fast path: no emoji handling needed ──────────────────────────────
|
|
163
|
+
if ((!emojiEnabled && !colorEmojiEnabled) || !hasEmoji) {
|
|
164
|
+
if (firstX !== undefined) {
|
|
165
|
+
doc.text(text, firstX, firstY, opts);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
doc.text(text, opts);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Remember the caller's font state so we can restore after emoji runs.
|
|
173
|
+
const prevFont = doc._font?.name ?? theme.body.font;
|
|
174
|
+
const prevSize = doc._fontSize ?? theme.body.fontSize;
|
|
175
|
+
const segments = (0, emoji_js_1.splitEmojiSegments)(text);
|
|
176
|
+
// ── Color emoji path: render emoji as inline PNG images ──────────────
|
|
177
|
+
// Strategy: two-pass rendering.
|
|
178
|
+
// Pass 1 – Render all text through doc.text() with `continued`,
|
|
179
|
+
// exactly like the monochrome path. For emoji segments,
|
|
180
|
+
// render a space-placeholder to reserve width. Read the
|
|
181
|
+
// real X position from PDFKit's internal wrapper state
|
|
182
|
+
// (_wrapper.startX + _wrapper.continuedX) — doc.x stays
|
|
183
|
+
// at the left margin after continued:true so it is NOT
|
|
184
|
+
// usable for positioning.
|
|
185
|
+
// Pass 2 – Overlay emoji PNG images at the recorded positions.
|
|
186
|
+
if (colorEmojiEnabled && emojiImageCache && emojiImageCache.size > 0) {
|
|
187
|
+
const emojiRe = (0, emoji_js_1.getEmojiRegex)();
|
|
188
|
+
const emojiSize = prevSize; // match font size
|
|
189
|
+
const emojiPlacements = [];
|
|
190
|
+
// Read the actual text-flow X from PDFKit's internal LineWrapper.
|
|
191
|
+
// After doc.text(…, {continued:true}), _wrapper.continuedX tracks
|
|
192
|
+
// cumulative rendered width; startX is the original doc.x at
|
|
193
|
+
// wrapper creation time.
|
|
194
|
+
const getFlowX = () => {
|
|
195
|
+
const w = doc._wrapper;
|
|
196
|
+
if (w)
|
|
197
|
+
return w.startX + w.continuedX;
|
|
198
|
+
return firstX ?? doc.x;
|
|
199
|
+
};
|
|
200
|
+
// Build a space-placeholder string whose width ≈ emojiSize.
|
|
201
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
202
|
+
const spaceW = doc.widthOfString(' ');
|
|
203
|
+
const spacesPerEmoji = Math.max(1, Math.round(emojiSize / spaceW));
|
|
204
|
+
const placeholder = ' '.repeat(spacesPerEmoji);
|
|
205
|
+
const placeholderW = doc.widthOfString(placeholder);
|
|
206
|
+
// Vertical alignment: centre emoji within the current line height.
|
|
207
|
+
const lineH = doc.currentLineHeight(true);
|
|
208
|
+
const yOffset = Math.max(0, (lineH - emojiSize) / 2);
|
|
209
|
+
// Only use explicit (firstX, firstY) coordinates on the very first
|
|
210
|
+
// doc.text() call, and only when no wrapper already exists (i.e.
|
|
211
|
+
// we are not continuing from a prior renderTextWithEmoji call).
|
|
212
|
+
let usedExplicitCoords = false;
|
|
213
|
+
for (let i = 0; i < segments.length; i++) {
|
|
214
|
+
const seg = segments[i];
|
|
215
|
+
const isLast = i === segments.length - 1;
|
|
216
|
+
const cont = isLast ? !!opts.continued : true;
|
|
217
|
+
if (seg.isEmoji) {
|
|
218
|
+
emojiRe.lastIndex = 0;
|
|
219
|
+
let em;
|
|
220
|
+
while ((em = emojiRe.exec(seg.text)) !== null) {
|
|
221
|
+
const png = emojiImageCache.get(em[0]);
|
|
222
|
+
const isLastEmoji = isLast && emojiRe.lastIndex >= seg.text.length;
|
|
223
|
+
const eCont = isLastEmoji ? !!opts.continued : true;
|
|
224
|
+
if (png) {
|
|
225
|
+
// Position BEFORE rendering the placeholder.
|
|
226
|
+
const emojiX = getFlowX();
|
|
227
|
+
// For positioned calls (table cells), doc.y may be stale from
|
|
228
|
+
// a previous cell — use the explicit firstY instead.
|
|
229
|
+
const emojiY = (!usedExplicitCoords && firstY !== undefined) ? firstY : doc.y;
|
|
230
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
231
|
+
if (!usedExplicitCoords && firstX !== undefined) {
|
|
232
|
+
doc.text(placeholder, firstX, firstY, { ...opts, continued: eCont });
|
|
233
|
+
usedExplicitCoords = true;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
doc.text(placeholder, { ...opts, continued: eCont });
|
|
237
|
+
}
|
|
238
|
+
// Compute alignment-aware X position for the emoji image.
|
|
239
|
+
let placedX = emojiX + (placeholderW - emojiSize) / 2;
|
|
240
|
+
if (firstX !== undefined && typeof opts.width === 'number' && !doc._wrapper) {
|
|
241
|
+
// Table cell with alignment — PDFKit already rendered the
|
|
242
|
+
// placeholder at the aligned position; match it.
|
|
243
|
+
const cellW = opts.width;
|
|
244
|
+
const a = opts.align || 'left';
|
|
245
|
+
if (a === 'center')
|
|
246
|
+
placedX = firstX + (cellW - emojiSize) / 2;
|
|
247
|
+
else if (a === 'right')
|
|
248
|
+
placedX = firstX + cellW - emojiSize;
|
|
249
|
+
}
|
|
250
|
+
emojiPlacements.push({
|
|
251
|
+
png,
|
|
252
|
+
x: placedX,
|
|
253
|
+
y: emojiY + yOffset,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
// Fallback: monochrome glyph.
|
|
258
|
+
if (emojiEnabled)
|
|
259
|
+
doc.font(EMOJI_FONT_NAME).fontSize(prevSize);
|
|
260
|
+
if (!usedExplicitCoords && firstX !== undefined) {
|
|
261
|
+
doc.text(em[0], firstX, firstY, { ...opts, continued: isLastEmoji ? !!opts.continued : true });
|
|
262
|
+
usedExplicitCoords = true;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
doc.text(em[0], { ...opts, continued: isLastEmoji ? !!opts.continued : true });
|
|
266
|
+
}
|
|
267
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// ── Text segment ──
|
|
273
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
274
|
+
if (!usedExplicitCoords && firstX !== undefined) {
|
|
275
|
+
doc.text(seg.text, firstX, firstY, { ...opts, continued: cont });
|
|
276
|
+
usedExplicitCoords = true;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
doc.text(seg.text, { ...opts, continued: cont });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// ── Pass 2: overlay emoji images at recorded positions ──
|
|
284
|
+
const savedY = doc.y;
|
|
285
|
+
const savedX = doc.x;
|
|
286
|
+
for (const ep of emojiPlacements) {
|
|
287
|
+
doc.image(ep.png, ep.x, ep.y, {
|
|
288
|
+
width: emojiSize,
|
|
289
|
+
height: emojiSize,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
doc.y = savedY;
|
|
293
|
+
doc.x = savedX;
|
|
294
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// ── Monochrome font path (original behaviour) ────────────────────────
|
|
298
|
+
for (let i = 0; i < segments.length; i++) {
|
|
299
|
+
const seg = segments[i];
|
|
300
|
+
const isLast = i === segments.length - 1;
|
|
301
|
+
const cont = isLast ? !!opts.continued : true;
|
|
302
|
+
if (seg.isEmoji) {
|
|
303
|
+
doc.font(EMOJI_FONT_NAME).fontSize(prevSize);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
307
|
+
}
|
|
308
|
+
// Only pass explicit x,y for the very first segment.
|
|
309
|
+
if (i === 0 && firstX !== undefined) {
|
|
310
|
+
doc.text(seg.text, firstX, firstY, { ...opts, continued: cont });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
doc.text(seg.text, { ...opts, continued: cont });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Restore the original font so subsequent calls aren't surprised.
|
|
317
|
+
doc.font(prevFont).fontSize(prevSize);
|
|
318
|
+
}
|
|
319
|
+
function renderCodespan(text, continued) {
|
|
320
|
+
const cs = theme.code.inline;
|
|
321
|
+
const hPad = 2; // horizontal padding each side
|
|
322
|
+
const vPad = 1; // vertical padding each side
|
|
323
|
+
doc.font(cs.font).fontSize(cs.fontSize);
|
|
324
|
+
const textW = doc.widthOfString(text);
|
|
325
|
+
const textH = doc.currentLineHeight();
|
|
326
|
+
// Determine flow position. If this is the first output in a table cell,
|
|
327
|
+
// use the cell context coordinates; otherwise read from the LineWrapper.
|
|
328
|
+
let flowX;
|
|
329
|
+
let flowY;
|
|
330
|
+
let useCellPos = false;
|
|
331
|
+
let cellExtra = {};
|
|
332
|
+
if (cellCtx && !cellCtx.used) {
|
|
333
|
+
flowX = cellCtx.x;
|
|
334
|
+
flowY = cellCtx.y;
|
|
335
|
+
useCellPos = true;
|
|
336
|
+
cellExtra = { width: cellCtx.width, align: cellCtx.align };
|
|
337
|
+
cellCtx.used = true;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// Read the real flow X position from PDFKit's internal LineWrapper.
|
|
341
|
+
// After continued:true, doc.x stays at the left margin — the actual
|
|
342
|
+
// cursor position is _wrapper.startX + _wrapper.continuedX.
|
|
343
|
+
const w = doc._wrapper;
|
|
344
|
+
flowX = w ? (w.startX + w.continuedX) : doc.x;
|
|
345
|
+
flowY = doc.y;
|
|
346
|
+
}
|
|
347
|
+
// Draw background at the current flow position (behind the text)
|
|
348
|
+
doc.save();
|
|
349
|
+
doc.roundedRect(flowX - hPad, flowY, textW + hPad * 2, textH + vPad * 2, 2)
|
|
350
|
+
.fill(cs.backgroundColor);
|
|
351
|
+
doc.restore();
|
|
352
|
+
// Render inline — use positioned form for cell context, flow form otherwise
|
|
353
|
+
doc.font(cs.font).fontSize(cs.fontSize).fillColor(cs.color);
|
|
354
|
+
if (useCellPos) {
|
|
355
|
+
doc.text(text, flowX, flowY, { continued, ...cellExtra });
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
doc.text(text, { continued });
|
|
359
|
+
}
|
|
360
|
+
resetBodyFont();
|
|
361
|
+
}
|
|
362
|
+
function renderLink(tok, continued) {
|
|
363
|
+
if (headingCtx) {
|
|
364
|
+
doc.font(headingCtx.font).fontSize(headingCtx.fontSize).fillColor(theme.linkColor);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
doc.font(theme.body.font).fontSize(theme.body.fontSize).fillColor(theme.linkColor);
|
|
368
|
+
}
|
|
369
|
+
const linkText = tok.text || tok.href;
|
|
370
|
+
renderTextWithEmoji(linkText, { continued, underline: true, link: tok.href });
|
|
371
|
+
doc.fillColor(headingCtx ? headingCtx.color : theme.body.color);
|
|
372
|
+
}
|
|
373
|
+
async function renderInlineTokens(inlineTokens, continued, insideBold = false, insideItalic = false) {
|
|
374
|
+
for (let i = 0; i < inlineTokens.length; i++) {
|
|
375
|
+
const isLast = i === inlineTokens.length - 1;
|
|
376
|
+
const cont = continued || !isLast;
|
|
377
|
+
const tok = inlineTokens[i];
|
|
378
|
+
switch (tok.type) {
|
|
379
|
+
case 'text': {
|
|
380
|
+
const t = tok;
|
|
381
|
+
if (t.tokens && t.tokens.length > 0) {
|
|
382
|
+
await renderInlineTokens(t.tokens, cont, insideBold, insideItalic);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
applyBodyFont(insideBold, insideItalic);
|
|
386
|
+
renderTextWithEmoji(t.text, { continued: cont, underline: false, strike: false });
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case 'strong': {
|
|
391
|
+
const t = tok;
|
|
392
|
+
await renderInlineTokens(t.tokens, cont, true, insideItalic);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case 'em': {
|
|
396
|
+
const t = tok;
|
|
397
|
+
await renderInlineTokens(t.tokens, cont, insideBold, true);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'codespan': {
|
|
401
|
+
renderCodespan(tok.text, cont);
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 'link': {
|
|
405
|
+
renderLink(tok, cont);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'image': {
|
|
409
|
+
await renderImage(tok);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case 'del': {
|
|
413
|
+
applyBodyFont(insideBold, insideItalic);
|
|
414
|
+
renderTextWithEmoji(tok.text, { continued: cont, strike: true, underline: false });
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
case 'escape': {
|
|
418
|
+
applyBodyFont(insideBold, insideItalic);
|
|
419
|
+
renderTextWithEmoji(tok.text, { continued: cont, underline: false, strike: false });
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case 'br': {
|
|
423
|
+
doc.moveDown(0.5);
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
default: {
|
|
427
|
+
const raw = tok.text ?? tok.raw ?? '';
|
|
428
|
+
if (raw) {
|
|
429
|
+
applyBodyFont(insideBold, insideItalic);
|
|
430
|
+
renderTextWithEmoji(raw, { continued: cont, underline: false, strike: false });
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async function renderImage(tok) {
|
|
438
|
+
try {
|
|
439
|
+
// Use the pluggable image renderer
|
|
440
|
+
const imgBuffer = await imageRenderer(tok.href);
|
|
441
|
+
// Read the image's intrinsic dimensions via pdfkit
|
|
442
|
+
// openImage exists at runtime but is missing from @types/pdfkit
|
|
443
|
+
const img = doc.openImage(imgBuffer);
|
|
444
|
+
const maxHeight = doc.page.height - margins.top - margins.bottom;
|
|
445
|
+
// Scale down to fit content area, but never scale up beyond natural size
|
|
446
|
+
let displayWidth = Math.min(img.width, contentWidth);
|
|
447
|
+
let displayHeight = img.height * (displayWidth / img.width);
|
|
448
|
+
// Also cap height to the printable area
|
|
449
|
+
if (displayHeight > maxHeight) {
|
|
450
|
+
displayHeight = maxHeight;
|
|
451
|
+
displayWidth = img.width * (displayHeight / img.height);
|
|
452
|
+
}
|
|
453
|
+
ensureSpace(displayHeight + 10);
|
|
454
|
+
doc.image(imgBuffer, { width: displayWidth, height: displayHeight });
|
|
455
|
+
doc.moveDown(0.5);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
ensureSpace(20);
|
|
459
|
+
resetBodyFont();
|
|
460
|
+
doc.text(`[Image: ${tok.text || 'image'}]`);
|
|
461
|
+
doc.moveDown(0.3);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function renderList(list, depth) {
|
|
465
|
+
const indent = margins.left + depth * 20;
|
|
466
|
+
for (let idx = 0; idx < list.items.length; idx++) {
|
|
467
|
+
const item = list.items[idx];
|
|
468
|
+
ensureSpace(theme.body.fontSize * 2);
|
|
469
|
+
resetBodyFont();
|
|
470
|
+
const bullet = list.ordered ? `${list.start + idx}.` : '•';
|
|
471
|
+
doc.text(bullet, indent, doc.y, { continued: true, width: contentWidth - depth * 20 });
|
|
472
|
+
doc.text(' ', { continued: true });
|
|
473
|
+
// Render item inline tokens
|
|
474
|
+
const itemTokens = item.tokens;
|
|
475
|
+
for (const child of itemTokens) {
|
|
476
|
+
if (child.type === 'text') {
|
|
477
|
+
const t = child;
|
|
478
|
+
if (t.tokens && t.tokens.length > 0) {
|
|
479
|
+
await renderInlineTokens(t.tokens, false);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
renderTextWithEmoji(t.text);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else if (child.type === 'paragraph') {
|
|
486
|
+
await renderInlineTokens(child.tokens, false);
|
|
487
|
+
}
|
|
488
|
+
else if (child.type === 'list') {
|
|
489
|
+
await renderList(child, depth + 1);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
doc.moveDown(0.2);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function renderCellTokens(cell, x, y, width, align, bold) {
|
|
496
|
+
const savedY = doc.y;
|
|
497
|
+
if (cell.tokens && cell.tokens.length > 0) {
|
|
498
|
+
cellCtx = { x, y, width, align, used: false };
|
|
499
|
+
applyBodyFont(bold, false);
|
|
500
|
+
await renderInlineTokens(cell.tokens, false, bold, false);
|
|
501
|
+
cellCtx = null;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// Fallback: plain text (no inline tokens)
|
|
505
|
+
applyBodyFont(bold, false);
|
|
506
|
+
renderTextWithEmoji(cell.text, x, y, { width, align });
|
|
507
|
+
}
|
|
508
|
+
doc.y = savedY;
|
|
509
|
+
}
|
|
510
|
+
async function renderTable(table) {
|
|
511
|
+
const colCount = table.header.length;
|
|
512
|
+
if (colCount === 0)
|
|
513
|
+
return;
|
|
514
|
+
const cellPad = theme.table.cellPadding;
|
|
515
|
+
const colWidth = contentWidth / colCount;
|
|
516
|
+
const rowH = theme.body.fontSize + cellPad * 2 + 4;
|
|
517
|
+
const textInsetY = (rowH - theme.body.fontSize) / 2;
|
|
518
|
+
ensureSpace(rowH * 2);
|
|
519
|
+
const startX = margins.left;
|
|
520
|
+
let y = doc.y;
|
|
521
|
+
// Header row
|
|
522
|
+
doc.save();
|
|
523
|
+
doc.rect(startX, y, contentWidth, rowH).fill(theme.table.headerBackground);
|
|
524
|
+
doc.restore();
|
|
525
|
+
for (let c = 0; c < colCount; c++) {
|
|
526
|
+
const cellX = startX + c * colWidth;
|
|
527
|
+
await renderCellTokens(table.header[c], cellX + cellPad, y + textInsetY, colWidth - cellPad * 2, table.align[c] || 'left', true);
|
|
528
|
+
}
|
|
529
|
+
// Header border
|
|
530
|
+
doc.save();
|
|
531
|
+
doc.strokeColor(theme.table.borderColor).lineWidth(0.5);
|
|
532
|
+
doc.rect(startX, y, contentWidth, rowH).stroke();
|
|
533
|
+
for (let c = 1; c < colCount; c++) {
|
|
534
|
+
const cx = startX + c * colWidth;
|
|
535
|
+
doc.moveTo(cx, y).lineTo(cx, y + rowH).stroke();
|
|
536
|
+
}
|
|
537
|
+
doc.restore();
|
|
538
|
+
y += rowH;
|
|
539
|
+
// Body rows
|
|
540
|
+
for (const row of table.rows) {
|
|
541
|
+
ensureSpace(rowH);
|
|
542
|
+
for (let c = 0; c < colCount; c++) {
|
|
543
|
+
const cellX = startX + c * colWidth;
|
|
544
|
+
await renderCellTokens(row[c], cellX + cellPad, y + textInsetY, colWidth - cellPad * 2, table.align[c] || 'left', false);
|
|
545
|
+
}
|
|
546
|
+
doc.save();
|
|
547
|
+
doc.strokeColor(theme.table.borderColor).lineWidth(0.5);
|
|
548
|
+
doc.rect(startX, y, contentWidth, rowH).stroke();
|
|
549
|
+
for (let c = 1; c < colCount; c++) {
|
|
550
|
+
const cx = startX + c * colWidth;
|
|
551
|
+
doc.moveTo(cx, y).lineTo(cx, y + rowH).stroke();
|
|
552
|
+
}
|
|
553
|
+
doc.restore();
|
|
554
|
+
y += rowH;
|
|
555
|
+
}
|
|
556
|
+
doc.x = margins.left;
|
|
557
|
+
doc.y = y;
|
|
558
|
+
doc.moveDown(0.5);
|
|
559
|
+
resetBodyFont();
|
|
560
|
+
}
|
|
561
|
+
async function renderToken(token) {
|
|
562
|
+
switch (token.type) {
|
|
563
|
+
case 'heading': {
|
|
564
|
+
const t = token;
|
|
565
|
+
const key = `h${t.depth}`;
|
|
566
|
+
const style = theme.headings[key];
|
|
567
|
+
const spaceAbove = style.fontSize * 0.8;
|
|
568
|
+
const spaceBelow = style.fontSize * 0.3;
|
|
569
|
+
ensureSpace(spaceAbove + style.fontSize + spaceBelow);
|
|
570
|
+
doc.moveDown(spaceAbove / doc.currentLineHeight());
|
|
571
|
+
doc.font(style.font).fontSize(style.fontSize).fillColor(style.color);
|
|
572
|
+
headingCtx = style;
|
|
573
|
+
if (t.tokens && t.tokens.length > 0) {
|
|
574
|
+
await renderInlineTokens(t.tokens, false, style.bold ?? false, style.italic ?? false);
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
renderTextWithEmoji(t.text);
|
|
578
|
+
}
|
|
579
|
+
headingCtx = null;
|
|
580
|
+
doc.moveDown(spaceBelow / doc.currentLineHeight());
|
|
581
|
+
resetBodyFont();
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case 'paragraph': {
|
|
585
|
+
const t = token;
|
|
586
|
+
ensureSpace(theme.body.fontSize * 2);
|
|
587
|
+
resetBodyFont();
|
|
588
|
+
await renderInlineTokens(t.tokens, false);
|
|
589
|
+
doc.moveDown(0.5);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
case 'code': {
|
|
593
|
+
const t = token;
|
|
594
|
+
const cs = theme.code.block;
|
|
595
|
+
// Use syntax highlighting when a language is specified and highlighting is enabled
|
|
596
|
+
if (syntaxHighlight && t.lang) {
|
|
597
|
+
const lines = t.text.split('\n');
|
|
598
|
+
const lineH = cs.fontSize * 1.5;
|
|
599
|
+
const blockH = lines.length * lineH + cs.padding * 2;
|
|
600
|
+
ensureSpace(blockH + 10);
|
|
601
|
+
const newY = (0, highlight_prism_js_1.renderCode)(doc, t.text, {
|
|
602
|
+
language: t.lang,
|
|
603
|
+
x: margins.left,
|
|
604
|
+
y: doc.y,
|
|
605
|
+
width: contentWidth,
|
|
606
|
+
font: cs.font,
|
|
607
|
+
fontSize: cs.fontSize,
|
|
608
|
+
lineHeight: 1.5,
|
|
609
|
+
padding: cs.padding,
|
|
610
|
+
lineNumbers: true,
|
|
611
|
+
drawBackground: true,
|
|
612
|
+
theme: theme.syntaxHighlight,
|
|
613
|
+
});
|
|
614
|
+
doc.x = margins.left;
|
|
615
|
+
doc.y = newY;
|
|
616
|
+
doc.moveDown(0.5);
|
|
617
|
+
resetBodyFont();
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
// Plain code block (no language or highlighting disabled)
|
|
621
|
+
const lines = t.text.split('\n');
|
|
622
|
+
const lineH = cs.fontSize * 1.4;
|
|
623
|
+
const blockH = lines.length * lineH + cs.padding * 2;
|
|
624
|
+
ensureSpace(blockH + 10);
|
|
625
|
+
const x = margins.left;
|
|
626
|
+
const y = doc.y;
|
|
627
|
+
doc.save();
|
|
628
|
+
doc.rect(x, y, contentWidth, blockH).fill(cs.backgroundColor);
|
|
629
|
+
doc.restore();
|
|
630
|
+
doc.font(cs.font).fontSize(cs.fontSize).fillColor(cs.color);
|
|
631
|
+
let textY = y + cs.padding;
|
|
632
|
+
for (const line of lines) {
|
|
633
|
+
doc.text(line, x + cs.padding, textY, { width: contentWidth - cs.padding * 2 });
|
|
634
|
+
textY += lineH;
|
|
635
|
+
}
|
|
636
|
+
doc.x = margins.left;
|
|
637
|
+
doc.y = y + blockH;
|
|
638
|
+
doc.moveDown(0.5);
|
|
639
|
+
resetBodyFont();
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
case 'blockquote': {
|
|
644
|
+
const t = token;
|
|
645
|
+
const bq = theme.blockquote;
|
|
646
|
+
ensureSpace(30);
|
|
647
|
+
const bqPadding = 6; // vertical padding above and below text
|
|
648
|
+
const startY = doc.y;
|
|
649
|
+
doc.y += bqPadding; // add top padding before text
|
|
650
|
+
const textX = margins.left + bq.borderWidth + bq.indent;
|
|
651
|
+
const textWidth = contentWidth - bq.borderWidth - bq.indent;
|
|
652
|
+
for (const child of t.tokens) {
|
|
653
|
+
if (child.type === 'paragraph') {
|
|
654
|
+
const p = child;
|
|
655
|
+
const font = bq.italic ? 'Helvetica-Oblique' : theme.body.font;
|
|
656
|
+
doc.font(font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
657
|
+
doc.text('', textX, doc.y, { width: textWidth });
|
|
658
|
+
await renderInlineTokens(p.tokens, false, false, bq.italic);
|
|
659
|
+
doc.moveDown(0.3);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
await renderToken(child);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
doc.y += bqPadding; // add bottom padding after text
|
|
666
|
+
const endY = doc.y;
|
|
667
|
+
doc.save();
|
|
668
|
+
doc.rect(margins.left, startY, bq.borderWidth, endY - startY).fill(bq.borderColor);
|
|
669
|
+
doc.restore();
|
|
670
|
+
doc.x = margins.left;
|
|
671
|
+
doc.moveDown(0.3);
|
|
672
|
+
resetBodyFont();
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
case 'list': {
|
|
676
|
+
await renderList(token, 0);
|
|
677
|
+
doc.moveDown(0.3);
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
case 'hr': {
|
|
681
|
+
ensureSpace(20);
|
|
682
|
+
doc.moveDown(0.5);
|
|
683
|
+
const y = doc.y;
|
|
684
|
+
doc.save();
|
|
685
|
+
doc.strokeColor(theme.horizontalRuleColor).lineWidth(1)
|
|
686
|
+
.moveTo(margins.left, y)
|
|
687
|
+
.lineTo(margins.left + contentWidth, y)
|
|
688
|
+
.stroke();
|
|
689
|
+
doc.restore();
|
|
690
|
+
doc.y = y;
|
|
691
|
+
doc.moveDown(0.5);
|
|
692
|
+
resetBodyFont();
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
case 'table': {
|
|
696
|
+
await renderTable(token);
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case 'image': {
|
|
700
|
+
await renderImage(token);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
case 'space':
|
|
704
|
+
case 'html':
|
|
705
|
+
break;
|
|
706
|
+
default:
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// ── Main loop ─────────────────────────────────────────────────────────────
|
|
711
|
+
for (const token of tokens) {
|
|
712
|
+
await renderToken(token);
|
|
713
|
+
}
|
|
714
|
+
doc.end();
|
|
715
|
+
return new Promise((resolve) => {
|
|
716
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
717
|
+
});
|
|
718
|
+
}
|