@speajus/markdown-to-pdf 1.0.14 → 1.0.16

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/renderer.js CHANGED
@@ -10,10 +10,15 @@ const marked_1 = require("marked");
10
10
  const stream_1 = require("stream");
11
11
  const defaults_js_1 = require("./defaults.js");
12
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';
13
+ /** Name used to identify the emoji font (for safeFont checks). */
14
+ const EMOJI_FONT_NAME = 'EmojiFont';
15
+ /** Standard PDF fonts built into PDFKit always available without filesystem access. */
16
+ const STANDARD_PDF_FONTS = new Set([
17
+ 'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
18
+ 'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
19
+ 'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic',
20
+ 'Symbol', 'ZapfDingbats',
21
+ ]);
17
22
  async function renderMarkdownToPdf(markdown, options) {
18
23
  const theme = options?.theme ?? styles_js_1.defaultTheme;
19
24
  const layout = options?.pageLayout ?? styles_js_1.defaultPageLayout;
@@ -24,49 +29,61 @@ async function renderMarkdownToPdf(markdown, options) {
24
29
  }
25
30
  const lineNumbers = options?.lineNumbers ?? false;
26
31
  const zebraStripes = options?.zebraStripes !== false;
27
- const emojiFontOpt = options?.emojiFont ?? true;
32
+ // ── Resolve effective emoji font setting ──────────────────────────────
33
+ // PdfOptions.emojiFont (boolean | string | Buffer) overrides theme when
34
+ // explicitly set; otherwise fall back to theme.emojiFont ('twemoji'|'openmoji'|'none').
35
+ const themeEmojiFont = theme.emojiFont ?? 'twemoji';
36
+ const emojiFontOpt = options?.emojiFont !== undefined
37
+ ? options.emojiFont
38
+ : themeEmojiFont === 'none'
39
+ ? false
40
+ : themeEmojiFont; // 'twemoji' | 'openmoji'
28
41
  // Use provided image renderer or create default Node.js renderer
29
42
  const imageRenderer = options?.renderImage ?? defaults_js_1.DEFAULTS.renderImage(basePath);
30
43
  const { margins } = layout;
31
- const doc = new pdfkit_1.default({ size: layout.pageSize, margins });
32
- const stream = new stream_1.PassThrough();
33
- const chunks = [];
34
- doc.pipe(stream);
35
- stream.on('data', (chunk) => chunks.push(chunk));
36
- // ── Emoji font registration ───────────────────────────────────────────────
37
- // The font registration block uses dynamic require() for `path` and `fs` so
38
- // that the renderer module can also be imported in browser environments where
39
- // those Node.js built-ins are unavailable. If they aren't available the
40
- // try/catch simply falls through and emoji support is disabled (unless the
41
- // caller passes a Buffer directly via the `emojiFont` option).
42
- let emojiEnabled = false;
44
+ // ── Resolve emoji font for pdfkit native color emoji support ────────────
45
+ // The fork at jspears/pdfkit#support-color-emoji-google handles emoji
46
+ // segmentation and rendering (COLR/CPAL, SBIX, CBDT) internally when
47
+ // an `emojiFont` option is passed to the PDFDocument constructor.
48
+ let resolvedEmojiFont;
43
49
  if (emojiFontOpt !== false) {
44
50
  try {
45
51
  if (Buffer.isBuffer(emojiFontOpt)) {
46
- // Raw font data — works in both Node.js and browser environments.
47
- doc.registerFont(EMOJI_FONT_NAME, emojiFontOpt);
48
- emojiEnabled = true;
52
+ resolvedEmojiFont = emojiFontOpt;
49
53
  }
50
54
  else {
51
- // Resolve a file path — requires Node.js built-ins.
52
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
53
56
  const nodePath = require('path');
54
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
55
58
  const nodeFs = require('fs');
56
- const fontPath = typeof emojiFontOpt === 'string'
57
- ? emojiFontOpt
58
- : nodePath.join(__dirname, 'fonts', 'NotoEmoji-Regular.ttf');
59
+ let fontPath;
60
+ if (typeof emojiFontOpt === 'string' && emojiFontOpt !== 'twemoji' && emojiFontOpt !== 'openmoji' && emojiFontOpt !== 'none') {
61
+ fontPath = emojiFontOpt; // custom file path
62
+ }
63
+ else if (emojiFontOpt === 'openmoji') {
64
+ fontPath = nodePath.join(__dirname, 'fonts', 'OpenMoji-Color.ttf');
65
+ }
66
+ else {
67
+ fontPath = nodePath.join(__dirname, 'fonts', 'Twemoji.Mozilla.ttf');
68
+ }
59
69
  if (nodeFs.existsSync(fontPath)) {
60
- doc.registerFont(EMOJI_FONT_NAME, fontPath);
61
- emojiEnabled = true;
70
+ resolvedEmojiFont = fontPath;
62
71
  }
63
72
  }
64
73
  }
65
74
  catch {
66
- // Font registration failed or Node.js APIs not available (browser) —
67
- // fall through without emoji support.
75
+ // Node.js APIs not available (browser) — no emoji font.
68
76
  }
69
77
  }
78
+ const doc = new pdfkit_1.default({
79
+ size: layout.pageSize,
80
+ margins,
81
+ ...(resolvedEmojiFont ? { emojiFont: resolvedEmojiFont } : {}),
82
+ });
83
+ const stream = new stream_1.PassThrough();
84
+ const chunks = [];
85
+ doc.pipe(stream);
86
+ stream.on('data', (chunk) => chunks.push(chunk));
70
87
  // ── Custom font registration ─────────────────────────────────────────────
71
88
  // Register user-supplied font families so they can be referenced by name
72
89
  // in ThemeConfig font fields. Each variant is registered with a suffix:
@@ -87,15 +104,22 @@ async function renderMarkdownToPdf(markdown, options) {
87
104
  }
88
105
  }
89
106
  }
90
- // ── Color emoji pre-render ─────────────────────────────────────────────
91
- // When a colorEmoji renderer is provided, pre-scan the entire markdown for
92
- // every unique emoji and convert them all to PNG buffers up-front. This
93
- // lets `renderTextWithEmoji` remain synchronous during page rendering.
94
- let emojiImageCache;
95
- const colorEmojiEnabled = !!options?.colorEmoji;
96
- if (options?.colorEmoji) {
97
- emojiImageCache = await (0, color_emoji_js_1.preRenderEmoji)(markdown, options.colorEmoji, (0, emoji_js_1.getEmojiRegex)());
107
+ // ── Browser font safety ────────────────────────────────────────────────
108
+ // In browser environments, `fs.readFileSync` doesn't exist. When PDFKit
109
+ // encounters an unknown font name it tries to load it from disk via
110
+ // readFileSync, which crashes in the browser. Detect whether we're in a
111
+ // filesystem-capable environment so we can guard calls to `doc.font()`.
112
+ let fsAvailable = false;
113
+ try {
114
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
115
+ const nodeFs = require('fs');
116
+ fsAvailable = typeof nodeFs.readFileSync === 'function';
117
+ }
118
+ catch {
119
+ // Node.js `fs` not available — we're in a browser environment.
98
120
  }
121
+ // ── Spacing config ────────────────────────────────────────────────────
122
+ const sp = { ...styles_js_1.defaultSpacing, ...theme.spacing };
99
123
  const tokens = marked_1.marked.lexer(markdown);
100
124
  const contentWidth = doc.page.width - margins.left - margins.right;
101
125
  // ── Table cell context ──────────────────────────────────────────────────
@@ -122,6 +146,38 @@ async function renderMarkdownToPdf(markdown, options) {
122
146
  return customFontNames.has(font.substring(0, dash));
123
147
  return false;
124
148
  }
149
+ /**
150
+ * Return a safe font name that will not crash `doc.font()`.
151
+ *
152
+ * In Node.js (`fsAvailable === true`) this is a no-op — PDFKit can load
153
+ * fonts from the filesystem. In browser environments, if the font is
154
+ * neither a standard PDF font nor a registered custom / emoji font, we
155
+ * return a standard fallback and log a warning so the user knows which
156
+ * font was unavailable.
157
+ */
158
+ function safeFont(name) {
159
+ if (fsAvailable)
160
+ return name;
161
+ if (STANDARD_PDF_FONTS.has(name) || isCustomFont(name) || name === EMOJI_FONT_NAME)
162
+ return name;
163
+ // Determine the closest standard fallback, preserving bold/italic intent
164
+ // by inspecting the variant suffix added by resolveFont().
165
+ let fallback;
166
+ if (name.endsWith('-BoldOblique') || name.endsWith('-BoldItalic')) {
167
+ fallback = 'Helvetica-BoldOblique';
168
+ }
169
+ else if (name.endsWith('-Bold')) {
170
+ fallback = 'Helvetica-Bold';
171
+ }
172
+ else if (name.endsWith('-Oblique') || name.endsWith('-Italic')) {
173
+ fallback = 'Helvetica-Oblique';
174
+ }
175
+ else {
176
+ fallback = 'Helvetica';
177
+ }
178
+ console.warn(`[markdown-to-pdf] Font "${name}" is not available; falling back to "${fallback}"`);
179
+ return fallback;
180
+ }
125
181
  /** Return the base (registered) name of a custom font, stripping any variant suffix. */
126
182
  function customFontBase(font) {
127
183
  if (customFontNames.has(font))
@@ -190,34 +246,30 @@ async function renderMarkdownToPdf(markdown, options) {
190
246
  let font = headingCtx.font;
191
247
  if (bold || italic)
192
248
  font = resolveFont(font, bold, italic);
193
- doc.font(font).fontSize(headingCtx.fontSize).fillColor(headingCtx.color);
249
+ doc.font(safeFont(font)).fontSize(headingCtx.fontSize).fillColor(headingCtx.color);
194
250
  return;
195
251
  }
196
252
  const font = resolveFont(theme.body.font, bold, italic);
197
- doc.font(font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
253
+ doc.font(safeFont(font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
198
254
  }
199
255
  function resetBodyFont() {
200
256
  if (headingCtx) {
201
- doc.font(headingCtx.font).fontSize(headingCtx.fontSize).fillColor(headingCtx.color);
257
+ doc.font(safeFont(headingCtx.font)).fontSize(headingCtx.fontSize).fillColor(headingCtx.color);
202
258
  return;
203
259
  }
204
- doc.font(theme.body.font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
260
+ doc.font(safeFont(theme.body.font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
205
261
  }
206
262
  /**
207
- * Render a text string switching to the emoji font for emoji characters.
263
+ * Render text, delegating to `doc.text()`.
208
264
  *
209
- * Preserves the caller's current font / fontSize / fillColor for the
210
- * non-emoji portions. The `continued` flag behaves exactly like the
211
- * native PDFKit option pass `true` to keep the line open.
265
+ * pdfkit's native color emoji support (via the `emojiFont` constructor
266
+ * option) handles emoji segmentation and rendering internally, so this
267
+ * function only normalises call signatures and handles table cell context.
212
268
  *
213
- * When `colorEmojiEnabled` is true and `emojiImageCache` is populated,
214
- * emoji characters are rendered as inline PNG images instead of font
215
- * glyphs, with manual (x, y) positioning to keep them in the text flow.
216
- *
217
- * Supports both `renderTextWithEmoji(text, opts?)` and the positioned
218
- * form `renderTextWithEmoji(text, x, y, opts?)` used by table cells.
269
+ * Supports both `renderText(text, opts?)` and the positioned form
270
+ * `renderText(text, x, y, opts?)` used by table cells.
219
271
  */
220
- function renderTextWithEmoji(text, xOrOpts, yOrUndefined, posOpts) {
272
+ function renderText(text, xOrOpts, yOrUndefined, posOpts) {
221
273
  // Normalise the two call signatures into a single (opts, firstX, firstY).
222
274
  let opts;
223
275
  let firstX;
@@ -237,169 +289,18 @@ async function renderMarkdownToPdf(markdown, options) {
237
289
  opts = { ...opts, width: cellCtx.width, align: cellCtx.align };
238
290
  cellCtx.used = true;
239
291
  }
240
- const hasEmoji = (0, emoji_js_1.containsEmoji)(text);
241
- // ── Fast path: no emoji handling needed ──────────────────────────────
242
- if ((!emojiEnabled && !colorEmojiEnabled) || !hasEmoji) {
243
- if (firstX !== undefined) {
244
- doc.text(text, firstX, firstY, opts);
245
- }
246
- else {
247
- doc.text(text, opts);
248
- }
249
- return;
292
+ if (firstX !== undefined) {
293
+ doc.text(text, firstX, firstY, opts);
250
294
  }
251
- // Remember the caller's font state so we can restore after emoji runs.
252
- const prevFont = doc._font?.name ?? theme.body.font;
253
- const prevSize = doc._fontSize ?? theme.body.fontSize;
254
- const segments = (0, emoji_js_1.splitEmojiSegments)(text);
255
- // ── Color emoji path: render emoji as inline PNG images ──────────────
256
- // Strategy: two-pass rendering.
257
- // Pass 1 – Render all text through doc.text() with `continued`,
258
- // exactly like the monochrome path. For emoji segments,
259
- // render a space-placeholder to reserve width. Read the
260
- // real X position from PDFKit's internal wrapper state
261
- // (_wrapper.startX + _wrapper.continuedX) — doc.x stays
262
- // at the left margin after continued:true so it is NOT
263
- // usable for positioning.
264
- // Pass 2 – Overlay emoji PNG images at the recorded positions.
265
- if (colorEmojiEnabled && emojiImageCache && emojiImageCache.size > 0) {
266
- const emojiRe = (0, emoji_js_1.getEmojiRegex)();
267
- const emojiSize = prevSize; // match font size
268
- const emojiPlacements = [];
269
- // Read the actual text-flow X from PDFKit's internal LineWrapper.
270
- // After doc.text(…, {continued:true}), _wrapper.continuedX tracks
271
- // cumulative rendered width; startX is the original doc.x at
272
- // wrapper creation time.
273
- const getFlowX = () => {
274
- const w = doc._wrapper;
275
- if (w)
276
- return w.startX + w.continuedX;
277
- return firstX ?? doc.x;
278
- };
279
- // Build a space-placeholder string whose width ≈ emojiSize.
280
- doc.font(prevFont).fontSize(prevSize);
281
- const spaceW = doc.widthOfString(' ');
282
- const spacesPerEmoji = Math.max(1, Math.round(emojiSize / spaceW));
283
- const placeholder = ' '.repeat(spacesPerEmoji);
284
- const placeholderW = doc.widthOfString(placeholder);
285
- // Vertical alignment: centre emoji within the current line height.
286
- const lineH = doc.currentLineHeight(true);
287
- const yOffset = Math.max(0, (lineH - emojiSize) / 2);
288
- // Only use explicit (firstX, firstY) coordinates on the very first
289
- // doc.text() call, and only when no wrapper already exists (i.e.
290
- // we are not continuing from a prior renderTextWithEmoji call).
291
- let usedExplicitCoords = false;
292
- for (let i = 0; i < segments.length; i++) {
293
- const seg = segments[i];
294
- const isLast = i === segments.length - 1;
295
- const cont = isLast ? !!opts.continued : true;
296
- if (seg.isEmoji) {
297
- emojiRe.lastIndex = 0;
298
- let em;
299
- while ((em = emojiRe.exec(seg.text)) !== null) {
300
- const png = emojiImageCache.get(em[0]);
301
- const isLastEmoji = isLast && emojiRe.lastIndex >= seg.text.length;
302
- const eCont = isLastEmoji ? !!opts.continued : true;
303
- if (png) {
304
- // Position BEFORE rendering the placeholder.
305
- const emojiX = getFlowX();
306
- // For positioned calls (table cells), doc.y may be stale from
307
- // a previous cell — use the explicit firstY instead.
308
- const emojiY = (!usedExplicitCoords && firstY !== undefined) ? firstY : doc.y;
309
- doc.font(prevFont).fontSize(prevSize);
310
- if (!usedExplicitCoords && firstX !== undefined) {
311
- doc.text(placeholder, firstX, firstY, { ...opts, continued: eCont });
312
- usedExplicitCoords = true;
313
- }
314
- else {
315
- doc.text(placeholder, { ...opts, continued: eCont });
316
- }
317
- // Compute alignment-aware X position for the emoji image.
318
- let placedX = emojiX + (placeholderW - emojiSize) / 2;
319
- if (firstX !== undefined && typeof opts.width === 'number' && !doc._wrapper) {
320
- // Table cell with alignment — PDFKit already rendered the
321
- // placeholder at the aligned position; match it.
322
- const cellW = opts.width;
323
- const a = opts.align || 'left';
324
- if (a === 'center')
325
- placedX = firstX + (cellW - emojiSize) / 2;
326
- else if (a === 'right')
327
- placedX = firstX + cellW - emojiSize;
328
- }
329
- emojiPlacements.push({
330
- png,
331
- x: placedX,
332
- y: emojiY + yOffset,
333
- });
334
- }
335
- else {
336
- // Fallback: monochrome glyph.
337
- if (emojiEnabled)
338
- doc.font(EMOJI_FONT_NAME).fontSize(prevSize);
339
- if (!usedExplicitCoords && firstX !== undefined) {
340
- doc.text(em[0], firstX, firstY, { ...opts, continued: isLastEmoji ? !!opts.continued : true });
341
- usedExplicitCoords = true;
342
- }
343
- else {
344
- doc.text(em[0], { ...opts, continued: isLastEmoji ? !!opts.continued : true });
345
- }
346
- doc.font(prevFont).fontSize(prevSize);
347
- }
348
- }
349
- }
350
- else {
351
- // ── Text segment ──
352
- doc.font(prevFont).fontSize(prevSize);
353
- if (!usedExplicitCoords && firstX !== undefined) {
354
- doc.text(seg.text, firstX, firstY, { ...opts, continued: cont });
355
- usedExplicitCoords = true;
356
- }
357
- else {
358
- doc.text(seg.text, { ...opts, continued: cont });
359
- }
360
- }
361
- }
362
- // ── Pass 2: overlay emoji images at recorded positions ──
363
- const savedY = doc.y;
364
- const savedX = doc.x;
365
- for (const ep of emojiPlacements) {
366
- doc.image(ep.png, ep.x, ep.y, {
367
- width: emojiSize,
368
- height: emojiSize,
369
- });
370
- }
371
- doc.y = savedY;
372
- doc.x = savedX;
373
- doc.font(prevFont).fontSize(prevSize);
374
- return;
375
- }
376
- // ── Monochrome font path (original behaviour) ────────────────────────
377
- for (let i = 0; i < segments.length; i++) {
378
- const seg = segments[i];
379
- const isLast = i === segments.length - 1;
380
- const cont = isLast ? !!opts.continued : true;
381
- if (seg.isEmoji) {
382
- doc.font(EMOJI_FONT_NAME).fontSize(prevSize);
383
- }
384
- else {
385
- doc.font(prevFont).fontSize(prevSize);
386
- }
387
- // Only pass explicit x,y for the very first segment.
388
- if (i === 0 && firstX !== undefined) {
389
- doc.text(seg.text, firstX, firstY, { ...opts, continued: cont });
390
- }
391
- else {
392
- doc.text(seg.text, { ...opts, continued: cont });
393
- }
295
+ else {
296
+ doc.text(text, opts);
394
297
  }
395
- // Restore the original font so subsequent calls aren't surprised.
396
- doc.font(prevFont).fontSize(prevSize);
397
298
  }
398
299
  function renderCodespan(text, continued) {
399
300
  const cs = theme.code.inline;
400
301
  const hPad = 2; // horizontal padding each side
401
302
  const vPad = 1; // vertical padding each side
402
- doc.font(cs.font).fontSize(cs.fontSize);
303
+ doc.font(safeFont(cs.font)).fontSize(cs.fontSize);
403
304
  const textW = doc.widthOfString(text);
404
305
  const textH = doc.currentLineHeight();
405
306
  // Determine flow position. If this is the first output in a table cell,
@@ -429,7 +330,7 @@ async function renderMarkdownToPdf(markdown, options) {
429
330
  .fill(cs.backgroundColor);
430
331
  doc.restore();
431
332
  // Render inline — use positioned form for cell context, flow form otherwise
432
- doc.font(cs.font).fontSize(cs.fontSize).fillColor(cs.color);
333
+ doc.font(safeFont(cs.font)).fontSize(cs.fontSize).fillColor(cs.color);
433
334
  if (useCellPos) {
434
335
  doc.text(text, flowX, flowY, { continued, ...cellExtra });
435
336
  }
@@ -445,13 +346,13 @@ async function renderMarkdownToPdf(markdown, options) {
445
346
  return renderImage(imgChild, tok.href);
446
347
  }
447
348
  if (headingCtx) {
448
- doc.font(headingCtx.font).fontSize(headingCtx.fontSize).fillColor(theme.linkColor);
349
+ doc.font(safeFont(headingCtx.font)).fontSize(headingCtx.fontSize).fillColor(theme.linkColor);
449
350
  }
450
351
  else {
451
- doc.font(theme.body.font).fontSize(theme.body.fontSize).fillColor(theme.linkColor);
352
+ doc.font(safeFont(theme.body.font)).fontSize(theme.body.fontSize).fillColor(theme.linkColor);
452
353
  }
453
354
  const linkText = tok.text || tok.href;
454
- renderTextWithEmoji(linkText, { continued, underline: true, link: tok.href });
355
+ renderText(linkText, { continued, underline: true, link: tok.href });
455
356
  doc.fillColor(headingCtx ? headingCtx.color : theme.body.color);
456
357
  }
457
358
  async function renderInlineTokens(inlineTokens, continued, insideBold = false, insideItalic = false) {
@@ -467,7 +368,7 @@ async function renderMarkdownToPdf(markdown, options) {
467
368
  }
468
369
  else {
469
370
  applyBodyFont(insideBold, insideItalic);
470
- renderTextWithEmoji(t.text, { continued: cont, underline: false, strike: false });
371
+ renderText(t.text, { continued: cont, underline: false, strike: false });
471
372
  }
472
373
  break;
473
374
  }
@@ -495,12 +396,12 @@ async function renderMarkdownToPdf(markdown, options) {
495
396
  }
496
397
  case 'del': {
497
398
  applyBodyFont(insideBold, insideItalic);
498
- renderTextWithEmoji(tok.text, { continued: cont, strike: true, underline: false });
399
+ renderText(tok.text, { continued: cont, strike: true, underline: false });
499
400
  break;
500
401
  }
501
402
  case 'escape': {
502
403
  applyBodyFont(insideBold, insideItalic);
503
- renderTextWithEmoji(tok.text, { continued: cont, underline: false, strike: false });
404
+ renderText(tok.text, { continued: cont, underline: false, strike: false });
504
405
  break;
505
406
  }
506
407
  case 'br': {
@@ -511,7 +412,7 @@ async function renderMarkdownToPdf(markdown, options) {
511
412
  const raw = tok.text ?? tok.raw ?? '';
512
413
  if (raw) {
513
414
  applyBodyFont(insideBold, insideItalic);
514
- renderTextWithEmoji(raw, { continued: cont, underline: false, strike: false });
415
+ renderText(raw, { continued: cont, underline: false, strike: false });
515
416
  }
516
417
  break;
517
418
  }
@@ -535,9 +436,12 @@ async function renderMarkdownToPdf(markdown, options) {
535
436
  displayWidth = img.width * (displayHeight / img.height);
536
437
  }
537
438
  ensureSpace(displayHeight + 10);
538
- const imgX = doc.x;
439
+ // Compute horizontal position based on imageAlign
440
+ const imgX = theme.imageAlign === 'center'
441
+ ? margins.left + (contentWidth - displayWidth) / 2
442
+ : doc.x;
539
443
  const imgY = doc.y;
540
- doc.image(imgBuffer, { width: displayWidth, height: displayHeight });
444
+ doc.image(imgBuffer, imgX, imgY, { width: displayWidth, height: displayHeight });
541
445
  // If the image is wrapped in a link, overlay a clickable annotation
542
446
  if (linkUrl) {
543
447
  doc.link(imgX, imgY, displayWidth, displayHeight, linkUrl);
@@ -552,13 +456,13 @@ async function renderMarkdownToPdf(markdown, options) {
552
456
  }
553
457
  }
554
458
  async function renderList(list, depth) {
555
- const indent = margins.left + depth * 20;
459
+ const indent = margins.left + depth * sp.listIndent;
556
460
  for (let idx = 0; idx < list.items.length; idx++) {
557
461
  const item = list.items[idx];
558
462
  ensureSpace(theme.body.fontSize * 2);
559
463
  resetBodyFont();
560
464
  const bullet = list.ordered ? `${list.start + idx}.` : '•';
561
- doc.text(bullet, indent, doc.y, { continued: true, width: contentWidth - depth * 20 });
465
+ doc.text(bullet, indent, doc.y, { continued: true, width: contentWidth - depth * sp.listIndent });
562
466
  doc.text(' ', { continued: true });
563
467
  // Render item inline tokens
564
468
  const itemTokens = item.tokens;
@@ -569,7 +473,7 @@ async function renderMarkdownToPdf(markdown, options) {
569
473
  await renderInlineTokens(t.tokens, false);
570
474
  }
571
475
  else {
572
- renderTextWithEmoji(t.text);
476
+ renderText(t.text);
573
477
  }
574
478
  }
575
479
  else if (child.type === 'paragraph') {
@@ -579,7 +483,7 @@ async function renderMarkdownToPdf(markdown, options) {
579
483
  await renderList(child, depth + 1);
580
484
  }
581
485
  }
582
- doc.moveDown(0.2);
486
+ doc.moveDown(sp.listItemSpacing);
583
487
  }
584
488
  }
585
489
  async function renderCellTokens(cell, x, y, width, align, bold) {
@@ -593,7 +497,7 @@ async function renderMarkdownToPdf(markdown, options) {
593
497
  else {
594
498
  // Fallback: plain text (no inline tokens)
595
499
  applyBodyFont(bold, false);
596
- renderTextWithEmoji(cell.text, x, y, { width, align });
500
+ renderText(cell.text, x, y, { width, align });
597
501
  }
598
502
  doc.y = savedY;
599
503
  }
@@ -662,17 +566,17 @@ async function renderMarkdownToPdf(markdown, options) {
662
566
  const t = token;
663
567
  const key = `h${t.depth}`;
664
568
  const style = theme.headings[key];
665
- const spaceAbove = style.fontSize * 0.8;
666
- const spaceBelow = style.fontSize * 0.3;
569
+ const spaceAbove = style.fontSize * sp.headingSpaceAbove;
570
+ const spaceBelow = style.fontSize * sp.headingSpaceBelow;
667
571
  ensureSpace(spaceAbove + style.fontSize + spaceBelow);
668
572
  doc.moveDown(spaceAbove / doc.currentLineHeight());
669
- doc.font(style.font).fontSize(style.fontSize).fillColor(style.color);
573
+ doc.font(safeFont(style.font)).fontSize(style.fontSize).fillColor(style.color);
670
574
  headingCtx = style;
671
575
  if (t.tokens && t.tokens.length > 0) {
672
576
  await renderInlineTokens(t.tokens, false, style.bold ?? false, style.italic ?? false);
673
577
  }
674
578
  else {
675
- renderTextWithEmoji(t.text);
579
+ renderText(t.text);
676
580
  }
677
581
  headingCtx = null;
678
582
  // Draw an underline beneath h1 and h2
@@ -695,7 +599,7 @@ async function renderMarkdownToPdf(markdown, options) {
695
599
  ensureSpace(theme.body.fontSize * 2);
696
600
  resetBodyFont();
697
601
  await renderInlineTokens(t.tokens, false);
698
- doc.moveDown(0.5);
602
+ doc.moveDown(sp.paragraphSpacing);
699
603
  break;
700
604
  }
701
605
  case 'code': {
@@ -712,17 +616,18 @@ async function renderMarkdownToPdf(markdown, options) {
712
616
  x: margins.left,
713
617
  y: doc.y,
714
618
  width: contentWidth,
715
- font: cs.font,
619
+ font: safeFont(cs.font),
716
620
  fontSize: cs.fontSize,
717
621
  lineHeight: 1.5,
718
622
  padding: cs.padding,
719
623
  lineNumbers,
720
624
  drawBackground: true,
721
625
  theme: theme.syntaxHighlight,
626
+ borderRadius: cs.borderRadius,
722
627
  });
723
628
  doc.x = margins.left;
724
629
  doc.y = newY;
725
- doc.moveDown(0.5);
630
+ doc.moveDown(sp.codeBlockSpacing);
726
631
  resetBodyFont();
727
632
  }
728
633
  else {
@@ -733,10 +638,16 @@ async function renderMarkdownToPdf(markdown, options) {
733
638
  ensureSpace(blockH + 10);
734
639
  const x = margins.left;
735
640
  const y = doc.y;
641
+ const cbRadius = cs.borderRadius ?? 0;
736
642
  doc.save();
737
- doc.rect(x, y, contentWidth, blockH).fill(cs.backgroundColor);
643
+ if (cbRadius > 0) {
644
+ doc.roundedRect(x, y, contentWidth, blockH, cbRadius).fill(cs.backgroundColor);
645
+ }
646
+ else {
647
+ doc.rect(x, y, contentWidth, blockH).fill(cs.backgroundColor);
648
+ }
738
649
  doc.restore();
739
- doc.font(cs.font).fontSize(cs.fontSize).fillColor(cs.color);
650
+ doc.font(safeFont(cs.font)).fontSize(cs.fontSize).fillColor(cs.color);
740
651
  let textY = y + cs.padding;
741
652
  for (const line of lines) {
742
653
  doc.text(line, x + cs.padding, textY, { width: contentWidth - cs.padding * 2 });
@@ -744,7 +655,7 @@ async function renderMarkdownToPdf(markdown, options) {
744
655
  }
745
656
  doc.x = margins.left;
746
657
  doc.y = y + blockH;
747
- doc.moveDown(0.5);
658
+ doc.moveDown(sp.codeBlockSpacing);
748
659
  resetBodyFont();
749
660
  }
750
661
  break;
@@ -753,7 +664,7 @@ async function renderMarkdownToPdf(markdown, options) {
753
664
  const t = token;
754
665
  const bq = theme.blockquote;
755
666
  ensureSpace(30);
756
- const bqPadding = 6; // vertical padding above and below text
667
+ const bqPadding = bq.padding ?? 6;
757
668
  const startY = doc.y;
758
669
  doc.y += bqPadding; // add top padding before text
759
670
  const textX = margins.left + bq.borderWidth + bq.indent;
@@ -762,10 +673,10 @@ async function renderMarkdownToPdf(markdown, options) {
762
673
  if (child.type === 'paragraph') {
763
674
  const p = child;
764
675
  const font = bq.italic ? italicVariant(theme.body.font) : theme.body.font;
765
- doc.font(font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
676
+ doc.font(safeFont(font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
766
677
  doc.text('', textX, doc.y, { width: textWidth });
767
678
  await renderInlineTokens(p.tokens, false, false, bq.italic);
768
- doc.moveDown(0.3);
679
+ doc.moveDown(sp.blockquoteSpacing);
769
680
  }
770
681
  else {
771
682
  await renderToken(child);
@@ -773,11 +684,18 @@ async function renderMarkdownToPdf(markdown, options) {
773
684
  }
774
685
  doc.y += bqPadding; // add bottom padding after text
775
686
  const endY = doc.y;
687
+ // Draw optional background fill behind blockquote area
688
+ if (bq.backgroundColor) {
689
+ doc.save();
690
+ doc.rect(margins.left + bq.borderWidth, startY, contentWidth - bq.borderWidth, endY - startY).fill(bq.backgroundColor);
691
+ doc.restore();
692
+ }
693
+ // Draw left border
776
694
  doc.save();
777
695
  doc.rect(margins.left, startY, bq.borderWidth, endY - startY).fill(bq.borderColor);
778
696
  doc.restore();
779
697
  doc.x = margins.left;
780
- doc.moveDown(0.3);
698
+ doc.moveDown(sp.blockquoteSpacing);
781
699
  resetBodyFont();
782
700
  break;
783
701
  }
@@ -788,7 +706,7 @@ async function renderMarkdownToPdf(markdown, options) {
788
706
  }
789
707
  case 'hr': {
790
708
  ensureSpace(20);
791
- doc.moveDown(0.5);
709
+ doc.moveDown(sp.hrSpacing);
792
710
  const y = doc.y;
793
711
  doc.save();
794
712
  doc.strokeColor(theme.horizontalRuleColor).lineWidth(1)
@@ -797,7 +715,7 @@ async function renderMarkdownToPdf(markdown, options) {
797
715
  .stroke();
798
716
  doc.restore();
799
717
  doc.y = y;
800
- doc.moveDown(0.5);
718
+ doc.moveDown(sp.hrSpacing);
801
719
  resetBodyFont();
802
720
  break;
803
721
  }
package/dist/styles.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { ThemeConfig, PageLayout, SyntaxHighlightTheme } from './types.js';
1
+ import type { ThemeConfig, PageLayout, SpacingConfig, SyntaxHighlightTheme } from './types.js';
2
+ /** Default spacing values — used as fallbacks when theme.spacing is partial or absent. */
3
+ export declare const defaultSpacing: Required<SpacingConfig>;
2
4
  /** Prism.js default light theme — used as the default (print-friendly). */
3
5
  export declare const defaultSyntaxHighlightTheme: SyntaxHighlightTheme;
4
6
  export declare const defaultTheme: ThemeConfig;