@speajus/markdown-to-pdf 1.0.15 → 1.0.17

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/browser.d.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  * Browser-specific entry point that excludes Node.js dependencies
3
3
  */
4
4
  export { createBrowserImageRenderer } from "./browser-image-renderer.js";
5
- export { createBrowserColorEmojiRenderer } from './color-emoji.js';
6
- export type { TextStyle, PageLayout, CodeStyle, CodeBlockStyle, BlockquoteStyle, TableStyles, TokenColors, SyntaxHighlightTheme, ThemeConfig, PdfOptions, ColorEmojiRenderer, CustomFontDefinition, } from './types.js';
7
- export { defaultTheme, defaultPageLayout, defaultSyntaxHighlightTheme } from './styles.js';
5
+ export type { TextStyle, PageLayout, CodeStyle, CodeBlockStyle, BlockquoteStyle, TableStyles, TokenColors, SyntaxHighlightTheme, SpacingConfig, ThemeConfig, PdfOptions, CustomFontDefinition, } from './types.js';
6
+ export { defaultTheme, defaultPageLayout, defaultSyntaxHighlightTheme, defaultSpacing } from './styles.js';
8
7
  export { themes, modernTheme, academicTheme, minimalTheme, oceanTheme } from './themes/index.js';
9
8
  export { renderMarkdownToPdf } from "./renderer.js";
package/dist/browser.js CHANGED
@@ -1,17 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.renderMarkdownToPdf = exports.oceanTheme = exports.minimalTheme = exports.academicTheme = exports.modernTheme = exports.themes = exports.defaultSyntaxHighlightTheme = exports.defaultPageLayout = exports.defaultTheme = exports.createBrowserColorEmojiRenderer = exports.createBrowserImageRenderer = void 0;
3
+ exports.renderMarkdownToPdf = exports.oceanTheme = exports.minimalTheme = exports.academicTheme = exports.modernTheme = exports.themes = exports.defaultSpacing = exports.defaultSyntaxHighlightTheme = exports.defaultPageLayout = exports.defaultTheme = exports.createBrowserImageRenderer = void 0;
4
4
  /**
5
5
  * Browser-specific entry point that excludes Node.js dependencies
6
6
  */
7
7
  var browser_image_renderer_js_1 = require("./browser-image-renderer.js");
8
8
  Object.defineProperty(exports, "createBrowserImageRenderer", { enumerable: true, get: function () { return browser_image_renderer_js_1.createBrowserImageRenderer; } });
9
- var color_emoji_js_1 = require("./color-emoji.js");
10
- Object.defineProperty(exports, "createBrowserColorEmojiRenderer", { enumerable: true, get: function () { return color_emoji_js_1.createBrowserColorEmojiRenderer; } });
11
9
  var styles_js_1 = require("./styles.js");
12
10
  Object.defineProperty(exports, "defaultTheme", { enumerable: true, get: function () { return styles_js_1.defaultTheme; } });
13
11
  Object.defineProperty(exports, "defaultPageLayout", { enumerable: true, get: function () { return styles_js_1.defaultPageLayout; } });
14
12
  Object.defineProperty(exports, "defaultSyntaxHighlightTheme", { enumerable: true, get: function () { return styles_js_1.defaultSyntaxHighlightTheme; } });
13
+ Object.defineProperty(exports, "defaultSpacing", { enumerable: true, get: function () { return styles_js_1.defaultSpacing; } });
15
14
  var index_js_1 = require("./themes/index.js");
16
15
  Object.defineProperty(exports, "themes", { enumerable: true, get: function () { return index_js_1.themes; } });
17
16
  Object.defineProperty(exports, "modernTheme", { enumerable: true, get: function () { return index_js_1.modernTheme; } });
Binary file
Binary file
Binary file
@@ -57,6 +57,8 @@ export interface RenderCodeOptions {
57
57
  lineNumbers?: boolean;
58
58
  drawBackground?: boolean;
59
59
  theme?: Partial<SyntaxHighlightTheme>;
60
+ /** Corner radius for the code block background. @default 0 */
61
+ borderRadius?: number;
60
62
  }
61
63
  export declare function colorFor(tokenType: string | null, theme: SyntaxHighlightTheme): string;
62
64
  /**
@@ -172,12 +172,17 @@ function renderCode(doc, code, opts) {
172
172
  : 0;
173
173
  const codeX = x + padding + gutterWidth;
174
174
  const blockHeight = lines.length * lineH + padding * 2;
175
+ const borderRadius = opts.borderRadius ?? 0;
175
176
  // --- Background ---
176
177
  if (drawBackground) {
177
- doc
178
- .save()
179
- .rect(x, y, blockWidth, blockHeight)
180
- .fill(theme.background);
178
+ doc.save();
179
+ if (borderRadius > 0) {
180
+ doc.roundedRect(x, y, blockWidth, blockHeight, borderRadius);
181
+ }
182
+ else {
183
+ doc.rect(x, y, blockWidth, blockHeight);
184
+ }
185
+ doc.fill(theme.background);
181
186
  doc.restore();
182
187
  }
183
188
  // --- Set font once ---
package/dist/index.d.ts CHANGED
@@ -1,8 +1,6 @@
1
- export type { TextStyle, PageLayout, CodeStyle, CodeBlockStyle, BlockquoteStyle, TableStyles, TokenColors, SyntaxHighlightTheme, ThemeConfig, PdfOptions, ColorEmojiRenderer, CustomFontDefinition, } from './types.js';
2
- export { defaultTheme, defaultPageLayout, defaultSyntaxHighlightTheme } from './styles.js';
1
+ export type { TextStyle, PageLayout, CodeStyle, CodeBlockStyle, BlockquoteStyle, TableStyles, TokenColors, SyntaxHighlightTheme, SpacingConfig, ThemeConfig, PdfOptions, CustomFontDefinition, } from './types.js';
2
+ export { defaultTheme, defaultPageLayout, defaultSyntaxHighlightTheme, defaultSpacing } from './styles.js';
3
3
  export { renderMarkdownToPdf as generatePdf, renderMarkdownToPdf, } from "./renderer.js";
4
4
  export { createBrowserImageRenderer } from './browser-image-renderer.js';
5
5
  export { createNodeImageRenderer } from './node-image-renderer.js';
6
- export { createBrowserColorEmojiRenderer } from './color-emoji.js';
7
- export { createNodeColorEmojiRenderer } from './node-color-emoji.js';
8
6
  export { loadHighlightLanguages } from './highlight.prism.js';
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.loadHighlightLanguages = exports.createNodeColorEmojiRenderer = exports.createBrowserColorEmojiRenderer = exports.createNodeImageRenderer = exports.createBrowserImageRenderer = exports.renderMarkdownToPdf = exports.generatePdf = exports.defaultSyntaxHighlightTheme = exports.defaultPageLayout = exports.defaultTheme = void 0;
3
+ exports.loadHighlightLanguages = exports.createNodeImageRenderer = exports.createBrowserImageRenderer = exports.renderMarkdownToPdf = exports.generatePdf = exports.defaultSpacing = exports.defaultSyntaxHighlightTheme = exports.defaultPageLayout = exports.defaultTheme = void 0;
4
4
  var styles_js_1 = require("./styles.js");
5
5
  Object.defineProperty(exports, "defaultTheme", { enumerable: true, get: function () { return styles_js_1.defaultTheme; } });
6
6
  Object.defineProperty(exports, "defaultPageLayout", { enumerable: true, get: function () { return styles_js_1.defaultPageLayout; } });
7
7
  Object.defineProperty(exports, "defaultSyntaxHighlightTheme", { enumerable: true, get: function () { return styles_js_1.defaultSyntaxHighlightTheme; } });
8
+ Object.defineProperty(exports, "defaultSpacing", { enumerable: true, get: function () { return styles_js_1.defaultSpacing; } });
8
9
  var renderer_js_1 = require("./renderer.js");
9
10
  Object.defineProperty(exports, "generatePdf", { enumerable: true, get: function () { return renderer_js_1.renderMarkdownToPdf; } });
10
11
  Object.defineProperty(exports, "renderMarkdownToPdf", { enumerable: true, get: function () { return renderer_js_1.renderMarkdownToPdf; } });
@@ -12,9 +13,5 @@ var browser_image_renderer_js_1 = require("./browser-image-renderer.js");
12
13
  Object.defineProperty(exports, "createBrowserImageRenderer", { enumerable: true, get: function () { return browser_image_renderer_js_1.createBrowserImageRenderer; } });
13
14
  var node_image_renderer_js_1 = require("./node-image-renderer.js");
14
15
  Object.defineProperty(exports, "createNodeImageRenderer", { enumerable: true, get: function () { return node_image_renderer_js_1.createNodeImageRenderer; } });
15
- var color_emoji_js_1 = require("./color-emoji.js");
16
- Object.defineProperty(exports, "createBrowserColorEmojiRenderer", { enumerable: true, get: function () { return color_emoji_js_1.createBrowserColorEmojiRenderer; } });
17
- var node_color_emoji_js_1 = require("./node-color-emoji.js");
18
- Object.defineProperty(exports, "createNodeColorEmojiRenderer", { enumerable: true, get: function () { return node_color_emoji_js_1.createNodeColorEmojiRenderer; } });
19
16
  var highlight_prism_js_1 = require("./highlight.prism.js");
20
17
  Object.defineProperty(exports, "loadHighlightLanguages", { enumerable: true, get: function () { return highlight_prism_js_1.loadHighlightLanguages; } });
package/dist/renderer.js CHANGED
@@ -10,10 +10,8 @@ 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';
17
15
  /** Standard PDF fonts built into PDFKit — always available without filesystem access. */
18
16
  const STANDARD_PDF_FONTS = new Set([
19
17
  'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
@@ -31,49 +29,64 @@ async function renderMarkdownToPdf(markdown, options) {
31
29
  }
32
30
  const lineNumbers = options?.lineNumbers ?? false;
33
31
  const zebraStripes = options?.zebraStripes !== false;
34
- 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' | 'noto'
35
41
  // Use provided image renderer or create default Node.js renderer
36
42
  const imageRenderer = options?.renderImage ?? defaults_js_1.DEFAULTS.renderImage(basePath);
37
43
  const { margins } = layout;
38
- const doc = new pdfkit_1.default({ size: layout.pageSize, margins });
39
- const stream = new stream_1.PassThrough();
40
- const chunks = [];
41
- doc.pipe(stream);
42
- stream.on('data', (chunk) => chunks.push(chunk));
43
- // ── Emoji font registration ───────────────────────────────────────────────
44
- // The font registration block uses dynamic require() for `path` and `fs` so
45
- // that the renderer module can also be imported in browser environments where
46
- // those Node.js built-ins are unavailable. If they aren't available the
47
- // try/catch simply falls through and emoji support is disabled (unless the
48
- // caller passes a Buffer directly via the `emojiFont` option).
49
- 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;
50
49
  if (emojiFontOpt !== false) {
51
50
  try {
52
51
  if (Buffer.isBuffer(emojiFontOpt)) {
53
- // Raw font data — works in both Node.js and browser environments.
54
- doc.registerFont(EMOJI_FONT_NAME, emojiFontOpt);
55
- emojiEnabled = true;
52
+ resolvedEmojiFont = emojiFontOpt;
56
53
  }
57
54
  else {
58
- // Resolve a file path — requires Node.js built-ins.
59
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
60
56
  const nodePath = require('path');
61
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
62
58
  const nodeFs = require('fs');
63
- const fontPath = typeof emojiFontOpt === 'string'
64
- ? emojiFontOpt
65
- : nodePath.join(__dirname, 'fonts', 'NotoEmoji-Regular.ttf');
59
+ let fontPath;
60
+ if (typeof emojiFontOpt === 'string' && emojiFontOpt !== 'twemoji' && emojiFontOpt !== 'openmoji' && emojiFontOpt !== 'noto' && 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 if (emojiFontOpt === 'noto') {
67
+ fontPath = nodePath.join(__dirname, 'fonts', 'NotoColorEmoji.ttf');
68
+ }
69
+ else {
70
+ fontPath = nodePath.join(__dirname, 'fonts', 'Twemoji.Mozilla.ttf');
71
+ }
66
72
  if (nodeFs.existsSync(fontPath)) {
67
- doc.registerFont(EMOJI_FONT_NAME, fontPath);
68
- emojiEnabled = true;
73
+ resolvedEmojiFont = fontPath;
69
74
  }
70
75
  }
71
76
  }
72
77
  catch {
73
- // Font registration failed or Node.js APIs not available (browser) —
74
- // fall through without emoji support.
78
+ // Node.js APIs not available (browser) — no emoji font.
75
79
  }
76
80
  }
81
+ const doc = new pdfkit_1.default({
82
+ size: layout.pageSize,
83
+ margins,
84
+ ...(resolvedEmojiFont ? { emojiFont: resolvedEmojiFont } : {}),
85
+ });
86
+ const stream = new stream_1.PassThrough();
87
+ const chunks = [];
88
+ doc.pipe(stream);
89
+ stream.on('data', (chunk) => chunks.push(chunk));
77
90
  // ── Custom font registration ─────────────────────────────────────────────
78
91
  // Register user-supplied font families so they can be referenced by name
79
92
  // in ThemeConfig font fields. Each variant is registered with a suffix:
@@ -108,15 +121,8 @@ async function renderMarkdownToPdf(markdown, options) {
108
121
  catch {
109
122
  // Node.js `fs` not available — we're in a browser environment.
110
123
  }
111
- // ── Color emoji pre-render ─────────────────────────────────────────────
112
- // When a colorEmoji renderer is provided, pre-scan the entire markdown for
113
- // every unique emoji and convert them all to PNG buffers up-front. This
114
- // lets `renderTextWithEmoji` remain synchronous during page rendering.
115
- let emojiImageCache;
116
- const colorEmojiEnabled = !!options?.colorEmoji;
117
- if (options?.colorEmoji) {
118
- emojiImageCache = await (0, color_emoji_js_1.preRenderEmoji)(markdown, options.colorEmoji, (0, emoji_js_1.getEmojiRegex)());
119
- }
124
+ // ── Spacing config ────────────────────────────────────────────────────
125
+ const sp = { ...styles_js_1.defaultSpacing, ...theme.spacing };
120
126
  const tokens = marked_1.marked.lexer(markdown);
121
127
  const contentWidth = doc.page.width - margins.left - margins.right;
122
128
  // ── Table cell context ──────────────────────────────────────────────────
@@ -257,20 +263,16 @@ async function renderMarkdownToPdf(markdown, options) {
257
263
  doc.font(safeFont(theme.body.font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
258
264
  }
259
265
  /**
260
- * Render a text string switching to the emoji font for emoji characters.
266
+ * Render text, delegating to `doc.text()`.
261
267
  *
262
- * Preserves the caller's current font / fontSize / fillColor for the
263
- * non-emoji portions. The `continued` flag behaves exactly like the
264
- * native PDFKit option pass `true` to keep the line open.
268
+ * pdfkit's native color emoji support (via the `emojiFont` constructor
269
+ * option) handles emoji segmentation and rendering internally, so this
270
+ * function only normalises call signatures and handles table cell context.
265
271
  *
266
- * When `colorEmojiEnabled` is true and `emojiImageCache` is populated,
267
- * emoji characters are rendered as inline PNG images instead of font
268
- * glyphs, with manual (x, y) positioning to keep them in the text flow.
269
- *
270
- * Supports both `renderTextWithEmoji(text, opts?)` and the positioned
271
- * form `renderTextWithEmoji(text, x, y, opts?)` used by table cells.
272
+ * Supports both `renderText(text, opts?)` and the positioned form
273
+ * `renderText(text, x, y, opts?)` used by table cells.
272
274
  */
273
- function renderTextWithEmoji(text, xOrOpts, yOrUndefined, posOpts) {
275
+ function renderText(text, xOrOpts, yOrUndefined, posOpts) {
274
276
  // Normalise the two call signatures into a single (opts, firstX, firstY).
275
277
  let opts;
276
278
  let firstX;
@@ -290,163 +292,12 @@ async function renderMarkdownToPdf(markdown, options) {
290
292
  opts = { ...opts, width: cellCtx.width, align: cellCtx.align };
291
293
  cellCtx.used = true;
292
294
  }
293
- const hasEmoji = (0, emoji_js_1.containsEmoji)(text);
294
- // ── Fast path: no emoji handling needed ──────────────────────────────
295
- if ((!emojiEnabled && !colorEmojiEnabled) || !hasEmoji) {
296
- if (firstX !== undefined) {
297
- doc.text(text, firstX, firstY, opts);
298
- }
299
- else {
300
- doc.text(text, opts);
301
- }
302
- return;
295
+ if (firstX !== undefined) {
296
+ doc.text(text, firstX, firstY, opts);
303
297
  }
304
- // Remember the caller's font state so we can restore after emoji runs.
305
- const prevFont = doc._font?.name ?? safeFont(theme.body.font);
306
- const prevSize = doc._fontSize ?? theme.body.fontSize;
307
- const segments = (0, emoji_js_1.splitEmojiSegments)(text);
308
- // ── Color emoji path: render emoji as inline PNG images ──────────────
309
- // Strategy: two-pass rendering.
310
- // Pass 1 – Render all text through doc.text() with `continued`,
311
- // exactly like the monochrome path. For emoji segments,
312
- // render a space-placeholder to reserve width. Read the
313
- // real X position from PDFKit's internal wrapper state
314
- // (_wrapper.startX + _wrapper.continuedX) — doc.x stays
315
- // at the left margin after continued:true so it is NOT
316
- // usable for positioning.
317
- // Pass 2 – Overlay emoji PNG images at the recorded positions.
318
- if (colorEmojiEnabled && emojiImageCache && emojiImageCache.size > 0) {
319
- const emojiRe = (0, emoji_js_1.getEmojiRegex)();
320
- const emojiSize = prevSize; // match font size
321
- const emojiPlacements = [];
322
- // Read the actual text-flow X from PDFKit's internal LineWrapper.
323
- // After doc.text(…, {continued:true}), _wrapper.continuedX tracks
324
- // cumulative rendered width; startX is the original doc.x at
325
- // wrapper creation time.
326
- const getFlowX = () => {
327
- const w = doc._wrapper;
328
- if (w)
329
- return w.startX + w.continuedX;
330
- return firstX ?? doc.x;
331
- };
332
- // Build a space-placeholder string whose width ≈ emojiSize.
333
- doc.font(prevFont).fontSize(prevSize);
334
- const spaceW = doc.widthOfString(' ');
335
- const spacesPerEmoji = Math.max(1, Math.round(emojiSize / spaceW));
336
- const placeholder = ' '.repeat(spacesPerEmoji);
337
- const placeholderW = doc.widthOfString(placeholder);
338
- // Vertical alignment: centre emoji within the current line height.
339
- const lineH = doc.currentLineHeight(true);
340
- const yOffset = Math.max(0, (lineH - emojiSize) / 2);
341
- // Only use explicit (firstX, firstY) coordinates on the very first
342
- // doc.text() call, and only when no wrapper already exists (i.e.
343
- // we are not continuing from a prior renderTextWithEmoji call).
344
- let usedExplicitCoords = false;
345
- for (let i = 0; i < segments.length; i++) {
346
- const seg = segments[i];
347
- const isLast = i === segments.length - 1;
348
- const cont = isLast ? !!opts.continued : true;
349
- if (seg.isEmoji) {
350
- emojiRe.lastIndex = 0;
351
- let em;
352
- while ((em = emojiRe.exec(seg.text)) !== null) {
353
- const png = emojiImageCache.get(em[0]);
354
- const isLastEmoji = isLast && emojiRe.lastIndex >= seg.text.length;
355
- const eCont = isLastEmoji ? !!opts.continued : true;
356
- if (png) {
357
- // Position BEFORE rendering the placeholder.
358
- const emojiX = getFlowX();
359
- // For positioned calls (table cells), doc.y may be stale from
360
- // a previous cell — use the explicit firstY instead.
361
- const emojiY = (!usedExplicitCoords && firstY !== undefined) ? firstY : doc.y;
362
- doc.font(prevFont).fontSize(prevSize);
363
- if (!usedExplicitCoords && firstX !== undefined) {
364
- doc.text(placeholder, firstX, firstY, { ...opts, continued: eCont });
365
- usedExplicitCoords = true;
366
- }
367
- else {
368
- doc.text(placeholder, { ...opts, continued: eCont });
369
- }
370
- // Compute alignment-aware X position for the emoji image.
371
- let placedX = emojiX + (placeholderW - emojiSize) / 2;
372
- if (firstX !== undefined && typeof opts.width === 'number' && !doc._wrapper) {
373
- // Table cell with alignment — PDFKit already rendered the
374
- // placeholder at the aligned position; match it.
375
- const cellW = opts.width;
376
- const a = opts.align || 'left';
377
- if (a === 'center')
378
- placedX = firstX + (cellW - emojiSize) / 2;
379
- else if (a === 'right')
380
- placedX = firstX + cellW - emojiSize;
381
- }
382
- emojiPlacements.push({
383
- png,
384
- x: placedX,
385
- y: emojiY + yOffset,
386
- });
387
- }
388
- else {
389
- // Fallback: monochrome glyph.
390
- if (emojiEnabled)
391
- doc.font(EMOJI_FONT_NAME).fontSize(prevSize);
392
- if (!usedExplicitCoords && firstX !== undefined) {
393
- doc.text(em[0], firstX, firstY, { ...opts, continued: isLastEmoji ? !!opts.continued : true });
394
- usedExplicitCoords = true;
395
- }
396
- else {
397
- doc.text(em[0], { ...opts, continued: isLastEmoji ? !!opts.continued : true });
398
- }
399
- doc.font(prevFont).fontSize(prevSize);
400
- }
401
- }
402
- }
403
- else {
404
- // ── Text segment ──
405
- doc.font(prevFont).fontSize(prevSize);
406
- if (!usedExplicitCoords && firstX !== undefined) {
407
- doc.text(seg.text, firstX, firstY, { ...opts, continued: cont });
408
- usedExplicitCoords = true;
409
- }
410
- else {
411
- doc.text(seg.text, { ...opts, continued: cont });
412
- }
413
- }
414
- }
415
- // ── Pass 2: overlay emoji images at recorded positions ──
416
- const savedY = doc.y;
417
- const savedX = doc.x;
418
- for (const ep of emojiPlacements) {
419
- doc.image(ep.png, ep.x, ep.y, {
420
- width: emojiSize,
421
- height: emojiSize,
422
- });
423
- }
424
- doc.y = savedY;
425
- doc.x = savedX;
426
- doc.font(prevFont).fontSize(prevSize);
427
- return;
428
- }
429
- // ── Monochrome font path (original behaviour) ────────────────────────
430
- for (let i = 0; i < segments.length; i++) {
431
- const seg = segments[i];
432
- const isLast = i === segments.length - 1;
433
- const cont = isLast ? !!opts.continued : true;
434
- if (seg.isEmoji) {
435
- doc.font(EMOJI_FONT_NAME).fontSize(prevSize);
436
- }
437
- else {
438
- doc.font(prevFont).fontSize(prevSize);
439
- }
440
- // Only pass explicit x,y for the very first segment.
441
- if (i === 0 && firstX !== undefined) {
442
- doc.text(seg.text, firstX, firstY, { ...opts, continued: cont });
443
- }
444
- else {
445
- doc.text(seg.text, { ...opts, continued: cont });
446
- }
298
+ else {
299
+ doc.text(text, opts);
447
300
  }
448
- // Restore the original font so subsequent calls aren't surprised.
449
- doc.font(prevFont).fontSize(prevSize);
450
301
  }
451
302
  function renderCodespan(text, continued) {
452
303
  const cs = theme.code.inline;
@@ -504,7 +355,7 @@ async function renderMarkdownToPdf(markdown, options) {
504
355
  doc.font(safeFont(theme.body.font)).fontSize(theme.body.fontSize).fillColor(theme.linkColor);
505
356
  }
506
357
  const linkText = tok.text || tok.href;
507
- renderTextWithEmoji(linkText, { continued, underline: true, link: tok.href });
358
+ renderText(linkText, { continued, underline: true, link: tok.href });
508
359
  doc.fillColor(headingCtx ? headingCtx.color : theme.body.color);
509
360
  }
510
361
  async function renderInlineTokens(inlineTokens, continued, insideBold = false, insideItalic = false) {
@@ -520,7 +371,7 @@ async function renderMarkdownToPdf(markdown, options) {
520
371
  }
521
372
  else {
522
373
  applyBodyFont(insideBold, insideItalic);
523
- renderTextWithEmoji(t.text, { continued: cont, underline: false, strike: false });
374
+ renderText(t.text, { continued: cont, underline: false, strike: false });
524
375
  }
525
376
  break;
526
377
  }
@@ -548,12 +399,12 @@ async function renderMarkdownToPdf(markdown, options) {
548
399
  }
549
400
  case 'del': {
550
401
  applyBodyFont(insideBold, insideItalic);
551
- renderTextWithEmoji(tok.text, { continued: cont, strike: true, underline: false });
402
+ renderText(tok.text, { continued: cont, strike: true, underline: false });
552
403
  break;
553
404
  }
554
405
  case 'escape': {
555
406
  applyBodyFont(insideBold, insideItalic);
556
- renderTextWithEmoji(tok.text, { continued: cont, underline: false, strike: false });
407
+ renderText(tok.text, { continued: cont, underline: false, strike: false });
557
408
  break;
558
409
  }
559
410
  case 'br': {
@@ -564,7 +415,7 @@ async function renderMarkdownToPdf(markdown, options) {
564
415
  const raw = tok.text ?? tok.raw ?? '';
565
416
  if (raw) {
566
417
  applyBodyFont(insideBold, insideItalic);
567
- renderTextWithEmoji(raw, { continued: cont, underline: false, strike: false });
418
+ renderText(raw, { continued: cont, underline: false, strike: false });
568
419
  }
569
420
  break;
570
421
  }
@@ -588,9 +439,12 @@ async function renderMarkdownToPdf(markdown, options) {
588
439
  displayWidth = img.width * (displayHeight / img.height);
589
440
  }
590
441
  ensureSpace(displayHeight + 10);
591
- const imgX = doc.x;
442
+ // Compute horizontal position based on imageAlign
443
+ const imgX = theme.imageAlign === 'center'
444
+ ? margins.left + (contentWidth - displayWidth) / 2
445
+ : doc.x;
592
446
  const imgY = doc.y;
593
- doc.image(imgBuffer, { width: displayWidth, height: displayHeight });
447
+ doc.image(imgBuffer, imgX, imgY, { width: displayWidth, height: displayHeight });
594
448
  // If the image is wrapped in a link, overlay a clickable annotation
595
449
  if (linkUrl) {
596
450
  doc.link(imgX, imgY, displayWidth, displayHeight, linkUrl);
@@ -605,13 +459,13 @@ async function renderMarkdownToPdf(markdown, options) {
605
459
  }
606
460
  }
607
461
  async function renderList(list, depth) {
608
- const indent = margins.left + depth * 20;
462
+ const indent = margins.left + depth * sp.listIndent;
609
463
  for (let idx = 0; idx < list.items.length; idx++) {
610
464
  const item = list.items[idx];
611
465
  ensureSpace(theme.body.fontSize * 2);
612
466
  resetBodyFont();
613
467
  const bullet = list.ordered ? `${list.start + idx}.` : '•';
614
- doc.text(bullet, indent, doc.y, { continued: true, width: contentWidth - depth * 20 });
468
+ doc.text(bullet, indent, doc.y, { continued: true, width: contentWidth - depth * sp.listIndent });
615
469
  doc.text(' ', { continued: true });
616
470
  // Render item inline tokens
617
471
  const itemTokens = item.tokens;
@@ -622,7 +476,7 @@ async function renderMarkdownToPdf(markdown, options) {
622
476
  await renderInlineTokens(t.tokens, false);
623
477
  }
624
478
  else {
625
- renderTextWithEmoji(t.text);
479
+ renderText(t.text);
626
480
  }
627
481
  }
628
482
  else if (child.type === 'paragraph') {
@@ -632,7 +486,7 @@ async function renderMarkdownToPdf(markdown, options) {
632
486
  await renderList(child, depth + 1);
633
487
  }
634
488
  }
635
- doc.moveDown(0.2);
489
+ doc.moveDown(sp.listItemSpacing);
636
490
  }
637
491
  }
638
492
  async function renderCellTokens(cell, x, y, width, align, bold) {
@@ -646,7 +500,7 @@ async function renderMarkdownToPdf(markdown, options) {
646
500
  else {
647
501
  // Fallback: plain text (no inline tokens)
648
502
  applyBodyFont(bold, false);
649
- renderTextWithEmoji(cell.text, x, y, { width, align });
503
+ renderText(cell.text, x, y, { width, align });
650
504
  }
651
505
  doc.y = savedY;
652
506
  }
@@ -715,8 +569,8 @@ async function renderMarkdownToPdf(markdown, options) {
715
569
  const t = token;
716
570
  const key = `h${t.depth}`;
717
571
  const style = theme.headings[key];
718
- const spaceAbove = style.fontSize * 0.8;
719
- const spaceBelow = style.fontSize * 0.3;
572
+ const spaceAbove = style.fontSize * sp.headingSpaceAbove;
573
+ const spaceBelow = style.fontSize * sp.headingSpaceBelow;
720
574
  ensureSpace(spaceAbove + style.fontSize + spaceBelow);
721
575
  doc.moveDown(spaceAbove / doc.currentLineHeight());
722
576
  doc.font(safeFont(style.font)).fontSize(style.fontSize).fillColor(style.color);
@@ -725,7 +579,7 @@ async function renderMarkdownToPdf(markdown, options) {
725
579
  await renderInlineTokens(t.tokens, false, style.bold ?? false, style.italic ?? false);
726
580
  }
727
581
  else {
728
- renderTextWithEmoji(t.text);
582
+ renderText(t.text);
729
583
  }
730
584
  headingCtx = null;
731
585
  // Draw an underline beneath h1 and h2
@@ -748,7 +602,7 @@ async function renderMarkdownToPdf(markdown, options) {
748
602
  ensureSpace(theme.body.fontSize * 2);
749
603
  resetBodyFont();
750
604
  await renderInlineTokens(t.tokens, false);
751
- doc.moveDown(0.5);
605
+ doc.moveDown(sp.paragraphSpacing);
752
606
  break;
753
607
  }
754
608
  case 'code': {
@@ -772,10 +626,11 @@ async function renderMarkdownToPdf(markdown, options) {
772
626
  lineNumbers,
773
627
  drawBackground: true,
774
628
  theme: theme.syntaxHighlight,
629
+ borderRadius: cs.borderRadius,
775
630
  });
776
631
  doc.x = margins.left;
777
632
  doc.y = newY;
778
- doc.moveDown(0.5);
633
+ doc.moveDown(sp.codeBlockSpacing);
779
634
  resetBodyFont();
780
635
  }
781
636
  else {
@@ -786,8 +641,14 @@ async function renderMarkdownToPdf(markdown, options) {
786
641
  ensureSpace(blockH + 10);
787
642
  const x = margins.left;
788
643
  const y = doc.y;
644
+ const cbRadius = cs.borderRadius ?? 0;
789
645
  doc.save();
790
- doc.rect(x, y, contentWidth, blockH).fill(cs.backgroundColor);
646
+ if (cbRadius > 0) {
647
+ doc.roundedRect(x, y, contentWidth, blockH, cbRadius).fill(cs.backgroundColor);
648
+ }
649
+ else {
650
+ doc.rect(x, y, contentWidth, blockH).fill(cs.backgroundColor);
651
+ }
791
652
  doc.restore();
792
653
  doc.font(safeFont(cs.font)).fontSize(cs.fontSize).fillColor(cs.color);
793
654
  let textY = y + cs.padding;
@@ -797,7 +658,7 @@ async function renderMarkdownToPdf(markdown, options) {
797
658
  }
798
659
  doc.x = margins.left;
799
660
  doc.y = y + blockH;
800
- doc.moveDown(0.5);
661
+ doc.moveDown(sp.codeBlockSpacing);
801
662
  resetBodyFont();
802
663
  }
803
664
  break;
@@ -806,7 +667,7 @@ async function renderMarkdownToPdf(markdown, options) {
806
667
  const t = token;
807
668
  const bq = theme.blockquote;
808
669
  ensureSpace(30);
809
- const bqPadding = 6; // vertical padding above and below text
670
+ const bqPadding = bq.padding ?? 6;
810
671
  const startY = doc.y;
811
672
  doc.y += bqPadding; // add top padding before text
812
673
  const textX = margins.left + bq.borderWidth + bq.indent;
@@ -818,7 +679,7 @@ async function renderMarkdownToPdf(markdown, options) {
818
679
  doc.font(safeFont(font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
819
680
  doc.text('', textX, doc.y, { width: textWidth });
820
681
  await renderInlineTokens(p.tokens, false, false, bq.italic);
821
- doc.moveDown(0.3);
682
+ doc.moveDown(sp.blockquoteSpacing);
822
683
  }
823
684
  else {
824
685
  await renderToken(child);
@@ -826,11 +687,18 @@ async function renderMarkdownToPdf(markdown, options) {
826
687
  }
827
688
  doc.y += bqPadding; // add bottom padding after text
828
689
  const endY = doc.y;
690
+ // Draw optional background fill behind blockquote area
691
+ if (bq.backgroundColor) {
692
+ doc.save();
693
+ doc.rect(margins.left + bq.borderWidth, startY, contentWidth - bq.borderWidth, endY - startY).fill(bq.backgroundColor);
694
+ doc.restore();
695
+ }
696
+ // Draw left border
829
697
  doc.save();
830
698
  doc.rect(margins.left, startY, bq.borderWidth, endY - startY).fill(bq.borderColor);
831
699
  doc.restore();
832
700
  doc.x = margins.left;
833
- doc.moveDown(0.3);
701
+ doc.moveDown(sp.blockquoteSpacing);
834
702
  resetBodyFont();
835
703
  break;
836
704
  }
@@ -841,7 +709,7 @@ async function renderMarkdownToPdf(markdown, options) {
841
709
  }
842
710
  case 'hr': {
843
711
  ensureSpace(20);
844
- doc.moveDown(0.5);
712
+ doc.moveDown(sp.hrSpacing);
845
713
  const y = doc.y;
846
714
  doc.save();
847
715
  doc.strokeColor(theme.horizontalRuleColor).lineWidth(1)
@@ -850,7 +718,7 @@ async function renderMarkdownToPdf(markdown, options) {
850
718
  .stroke();
851
719
  doc.restore();
852
720
  doc.y = y;
853
- doc.moveDown(0.5);
721
+ doc.moveDown(sp.hrSpacing);
854
722
  resetBodyFont();
855
723
  break;
856
724
  }
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;
package/dist/styles.js CHANGED
@@ -1,6 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultPageLayout = exports.defaultTheme = exports.defaultSyntaxHighlightTheme = void 0;
3
+ exports.defaultPageLayout = exports.defaultTheme = exports.defaultSyntaxHighlightTheme = exports.defaultSpacing = void 0;
4
+ /** Default spacing values — used as fallbacks when theme.spacing is partial or absent. */
5
+ exports.defaultSpacing = {
6
+ headingSpaceAbove: 0.8,
7
+ headingSpaceBelow: 0.3,
8
+ paragraphSpacing: 0.5,
9
+ listItemSpacing: 0.2,
10
+ listIndent: 20,
11
+ blockquoteSpacing: 0.3,
12
+ codeBlockSpacing: 0.5,
13
+ hrSpacing: 0.5,
14
+ };
4
15
  /** Prism.js default light theme — used as the default (print-friendly). */
5
16
  exports.defaultSyntaxHighlightTheme = {
6
17
  background: '#f5f2f0',
@@ -81,6 +92,9 @@ exports.defaultTheme = {
81
92
  zebraColor: '#f9f9f9',
82
93
  },
83
94
  syntaxHighlight: exports.defaultSyntaxHighlightTheme,
95
+ spacing: { ...exports.defaultSpacing },
96
+ imageAlign: 'left',
97
+ emojiFont: 'twemoji',
84
98
  };
85
99
  exports.defaultPageLayout = {
86
100
  pageSize: 'LETTER',
@@ -39,6 +39,9 @@ exports.modernTheme = {
39
39
  default: '#2d3436',
40
40
  },
41
41
  },
42
+ spacing: { ...styles_js_1.defaultSpacing },
43
+ imageAlign: 'left',
44
+ emojiFont: 'twemoji',
42
45
  };
43
46
  /** Academic — serif fonts, formal look inspired by LaTeX */
44
47
  exports.academicTheme = {
@@ -75,6 +78,9 @@ exports.academicTheme = {
75
78
  default: '#1a1a2e',
76
79
  },
77
80
  },
81
+ spacing: { ...styles_js_1.defaultSpacing },
82
+ imageAlign: 'left',
83
+ emojiFont: 'twemoji',
78
84
  };
79
85
  /** Minimal — lots of whitespace, muted greys */
80
86
  exports.minimalTheme = {
@@ -111,6 +117,9 @@ exports.minimalTheme = {
111
117
  default: '#333333',
112
118
  },
113
119
  },
120
+ spacing: { ...styles_js_1.defaultSpacing },
121
+ imageAlign: 'left',
122
+ emojiFont: 'twemoji',
114
123
  };
115
124
  /** Ocean — deep blue palette */
116
125
  exports.oceanTheme = {
@@ -147,6 +156,9 @@ exports.oceanTheme = {
147
156
  default: '#1b2631',
148
157
  },
149
158
  },
159
+ spacing: { ...styles_js_1.defaultSpacing },
160
+ imageAlign: 'left',
161
+ emojiFont: 'twemoji',
150
162
  };
151
163
  /** Record of all built-in themes keyed by display name */
152
164
  exports.themes = {
package/dist/types.d.ts CHANGED
@@ -23,12 +23,15 @@ export interface CodeStyle {
23
23
  }
24
24
  export interface CodeBlockStyle extends CodeStyle {
25
25
  padding: number;
26
+ borderRadius?: number;
26
27
  }
27
28
  export interface BlockquoteStyle {
28
29
  borderColor: string;
29
30
  borderWidth: number;
30
31
  italic: boolean;
31
32
  indent: number;
33
+ backgroundColor?: string;
34
+ padding?: number;
32
35
  }
33
36
  export interface TableStyles {
34
37
  headerBackground: string;
@@ -49,6 +52,16 @@ export interface SyntaxHighlightTheme {
49
52
  lineHighlight: string;
50
53
  tokens: TokenColors;
51
54
  }
55
+ export interface SpacingConfig {
56
+ headingSpaceAbove?: number;
57
+ headingSpaceBelow?: number;
58
+ paragraphSpacing?: number;
59
+ listItemSpacing?: number;
60
+ listIndent?: number;
61
+ blockquoteSpacing?: number;
62
+ codeBlockSpacing?: number;
63
+ hrSpacing?: number;
64
+ }
52
65
  export interface ThemeConfig {
53
66
  headings: {
54
67
  h1: TextStyle;
@@ -72,6 +85,17 @@ export interface ThemeConfig {
72
85
  * When omitted, falls back to a VS Code Dark+ inspired palette.
73
86
  */
74
87
  syntaxHighlight?: SyntaxHighlightTheme;
88
+ /** Configurable spacing multipliers and indentation values. */
89
+ spacing?: SpacingConfig;
90
+ /** Image horizontal alignment. */
91
+ imageAlign?: 'left' | 'center';
92
+ /** Emoji font to use for rendering emoji characters.
93
+ * - `'twemoji'` (default) — use the bundled Twemoji.Mozilla.ttf color emoji font.
94
+ * - `'openmoji'` — use the bundled OpenMoji-Color.ttf (COLR) emoji font.
95
+ * - `'noto'` — use the bundled NotoColorEmoji.ttf (CBDT) emoji font.
96
+ * - `'none'` — disable emoji font; emoji render with the body font.
97
+ */
98
+ emojiFont?: 'twemoji' | 'openmoji' | 'noto' | 'none';
75
99
  }
76
100
  /**
77
101
  * A custom font definition providing font data for registration with PDFKit.
@@ -92,8 +116,6 @@ export interface CustomFontDefinition {
92
116
  /** Bold-italic variant (falls back to bold or regular). */
93
117
  boldItalic?: Buffer;
94
118
  }
95
- /** Converts a single emoji string to a PNG `Buffer`. */
96
- export type ColorEmojiRenderer = (emoji: string) => Promise<Buffer>;
97
119
  export interface PdfOptions {
98
120
  theme?: ThemeConfig;
99
121
  pageLayout?: PageLayout;
@@ -151,20 +173,6 @@ export interface PdfOptions {
151
173
  * @default true
152
174
  */
153
175
  emojiFont?: boolean | string | Buffer;
154
- /**
155
- * Color emoji renderer.
156
- *
157
- * When provided, emoji characters are rendered as inline color PNG images
158
- * (sourced from Twemoji SVGs) instead of monochrome font glyphs.
159
- *
160
- * Use `createNodeColorEmojiRenderer()` (Node.js) or
161
- * `createBrowserColorEmojiRenderer()` (browser) to obtain a renderer.
162
- *
163
- * Takes priority over `emojiFont` for emoji that are successfully rendered.
164
- * Emoji that fail to render (e.g. missing from Twemoji) fall back to the
165
- * monochrome font or the body font.
166
- */
167
- colorEmoji?: ColorEmojiRenderer;
168
176
  /**
169
177
  * Custom font definitions to register with PDFKit.
170
178
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speajus/markdown-to-pdf",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "A new project created with Intent by Augment.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -29,6 +29,8 @@
29
29
  "README.md"
30
30
  ],
31
31
  "scripts": {
32
+ "build:pdfkit": "node -e \"const fs=require('fs'),p=require('path'),{execSync:e}=require('child_process'),d=p.join('node_modules','pdfkit','js');if(!fs.existsSync(d)){const t='/tmp/pdfkit-build-'+Date.now();e('git clone --depth 1 --branch support-color-emoji-google https://github.com/jspears/pdfkit.git '+t,{stdio:'inherit'});const pd=p.resolve('node_modules/pdfkit');for(const f of['lib','rollup.config.js','.babelrc','yarn.lock']){const s=p.join(t,f);if(fs.existsSync(s)){e('cp -r '+s+' '+pd,{stdio:'inherit'})}}e('cd '+pd+' && npm install --ignore-scripts && npx rollup -c',{stdio:'inherit'});e('rm -rf '+t,{stdio:'inherit'})}\"",
33
+ "postinstall": "node -e \"if(require('fs').existsSync('src')){require('child_process').execSync('npm run build:pdfkit',{stdio:'inherit'})}\" || true && node scripts/patch-fontkit-colr.js",
32
34
  "docs:dev": "cd docs && $npm_execpath dev",
33
35
  "build": "tsc && mkdir -p dist/fonts && cp src/fonts/* dist/fonts/",
34
36
  "generate": "tsx samples/generate.ts",
@@ -50,7 +52,7 @@
50
52
  "dependencies": {
51
53
  "@resvg/resvg-js": "^2.6.2",
52
54
  "marked": "^17.0.2",
53
- "pdfkit": "^0.17.2",
55
+ "pdfkit": "github:jspears/pdfkit#support-color-emoji-google",
54
56
  "prismjs": "^1.30.0"
55
57
  },
56
58
  "devDependencies": {
@@ -1,43 +0,0 @@
1
- /**
2
- * Color emoji rendering via SVG → PNG conversion.
3
- *
4
- * Converts emoji characters to color PNG images using Twemoji SVG assets,
5
- * with factory functions for Node.js and browser environments.
6
- */
7
- /** Pixel size at which emoji PNGs are rasterised (scaled to font size by PDFKit). */
8
- export declare const EMOJI_RENDER_SIZE = 128;
9
- /**
10
- * Convert an emoji string to its Twemoji SVG filename (without extension).
11
- *
12
- * Twemoji filenames use lower-case hex codepoints separated by hyphens,
13
- * with the variation selector U+FE0F omitted.
14
- *
15
- * @example
16
- * emojiToTwemojiCodepoints('🎉') // '1f389'
17
- * emojiToTwemojiCodepoints('👨‍👩‍👧‍👦') // '1f468-200d-1f469-200d-1f467-200d-1f466'
18
- */
19
- export declare function emojiToTwemojiCodepoints(emoji: string): string;
20
- /** Build the full Twemoji CDN URL for a single emoji. */
21
- export declare function twemojiSvgUrl(emoji: string): string;
22
- /** Ensure the SVG has explicit pixel dimensions for consistent rasterisation. */
23
- export declare function sizeSvg(svg: string, size: number): string;
24
- /** Converts a single emoji string to a PNG `Buffer`. */
25
- export type ColorEmojiRenderer = (emoji: string) => Promise<Buffer>;
26
- /**
27
- * Creates a color-emoji renderer for **browser** environments.
28
- *
29
- * Fetches Twemoji SVGs via `fetch()`, rasterises them on a `<canvas>`, and
30
- * returns a PNG `Buffer`. Results are cached.
31
- */
32
- export declare function createBrowserColorEmojiRenderer(): ColorEmojiRenderer;
33
- /**
34
- * Scans the full markdown text for all unique emoji and pre-renders them to
35
- * PNG `Buffer`s. The returned `Map` allows `renderTextWithEmoji` to stay
36
- * synchronous during rendering.
37
- *
38
- * @param text The raw markdown (or any text) to scan.
39
- * @param renderer A `ColorEmojiRenderer` (Node.js or browser factory).
40
- * @param emojiRe The emoji-matching regex (from `emoji.ts`).
41
- * @returns A `Map` from individual emoji string → PNG Buffer.
42
- */
43
- export declare function preRenderEmoji(text: string, renderer: ColorEmojiRenderer, emojiRe: RegExp): Promise<Map<string, Buffer>>;
@@ -1,142 +0,0 @@
1
- "use strict";
2
- /**
3
- * Color emoji rendering via SVG → PNG conversion.
4
- *
5
- * Converts emoji characters to color PNG images using Twemoji SVG assets,
6
- * with factory functions for Node.js and browser environments.
7
- */
8
- Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.EMOJI_RENDER_SIZE = void 0;
10
- exports.emojiToTwemojiCodepoints = emojiToTwemojiCodepoints;
11
- exports.twemojiSvgUrl = twemojiSvgUrl;
12
- exports.sizeSvg = sizeSvg;
13
- exports.createBrowserColorEmojiRenderer = createBrowserColorEmojiRenderer;
14
- exports.preRenderEmoji = preRenderEmoji;
15
- /**
16
- * Twemoji SVG CDN base URL.
17
- * Uses the community-maintained fork (`jdecked/twemoji`) since the original
18
- * Twitter project was discontinued.
19
- */
20
- const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/svg/';
21
- /** Pixel size at which emoji PNGs are rasterised (scaled to font size by PDFKit). */
22
- exports.EMOJI_RENDER_SIZE = 128;
23
- // ── Codepoint helpers ──────────────────────────────────────────────────────
24
- /**
25
- * Convert an emoji string to its Twemoji SVG filename (without extension).
26
- *
27
- * Twemoji filenames use lower-case hex codepoints separated by hyphens,
28
- * with the variation selector U+FE0F omitted.
29
- *
30
- * @example
31
- * emojiToTwemojiCodepoints('🎉') // '1f389'
32
- * emojiToTwemojiCodepoints('👨‍👩‍👧‍👦') // '1f468-200d-1f469-200d-1f467-200d-1f466'
33
- */
34
- function emojiToTwemojiCodepoints(emoji) {
35
- const cps = [];
36
- for (const ch of emoji) {
37
- const cp = ch.codePointAt(0);
38
- if (cp === undefined)
39
- continue;
40
- // Twemoji filenames omit VS16 (U+FE0F)
41
- if (cp === 0xfe0f)
42
- continue;
43
- cps.push(cp.toString(16));
44
- }
45
- return cps.join('-');
46
- }
47
- /** Build the full Twemoji CDN URL for a single emoji. */
48
- function twemojiSvgUrl(emoji) {
49
- return `${TWEMOJI_BASE}${emojiToTwemojiCodepoints(emoji)}.svg`;
50
- }
51
- // ── SVG sizing ─────────────────────────────────────────────────────────────
52
- /** Ensure the SVG has explicit pixel dimensions for consistent rasterisation. */
53
- function sizeSvg(svg, size) {
54
- // Replace existing width/height attributes …
55
- let out = svg;
56
- if (/width="[^"]*"/.test(out)) {
57
- out = out.replace(/width="[^"]*"/, `width="${size}"`);
58
- out = out.replace(/height="[^"]*"/, `height="${size}"`);
59
- return out;
60
- }
61
- // … or insert them.
62
- return out.replace('<svg', `<svg width="${size}" height="${size}"`);
63
- }
64
- // ── Browser factory ─────────────────────────────────────────────────────────
65
- /**
66
- * Creates a color-emoji renderer for **browser** environments.
67
- *
68
- * Fetches Twemoji SVGs via `fetch()`, rasterises them on a `<canvas>`, and
69
- * returns a PNG `Buffer`. Results are cached.
70
- */
71
- function createBrowserColorEmojiRenderer() {
72
- const cache = new Map();
73
- return async (emoji) => {
74
- const hit = cache.get(emoji);
75
- if (hit)
76
- return hit;
77
- const url = twemojiSvgUrl(emoji);
78
- const res = await fetch(url);
79
- if (!res.ok)
80
- throw new Error(`HTTP ${res.status} fetching ${url}`);
81
- const svgText = await res.text();
82
- const sized = sizeSvg(svgText, exports.EMOJI_RENDER_SIZE);
83
- // Render SVG → Canvas → PNG Blob → Buffer
84
- const png = await new Promise((resolve, reject) => {
85
- const img = new Image();
86
- img.onload = () => {
87
- const canvas = document.createElement('canvas');
88
- canvas.width = exports.EMOJI_RENDER_SIZE;
89
- canvas.height = exports.EMOJI_RENDER_SIZE;
90
- const ctx = canvas.getContext('2d');
91
- if (!ctx) {
92
- reject(new Error('Canvas 2D context unavailable'));
93
- return;
94
- }
95
- ctx.drawImage(img, 0, 0, exports.EMOJI_RENDER_SIZE, exports.EMOJI_RENDER_SIZE);
96
- canvas.toBlob((blob) => {
97
- if (!blob) {
98
- reject(new Error('toBlob failed'));
99
- return;
100
- }
101
- blob.arrayBuffer().then((ab) => resolve(Buffer.from(ab)), reject);
102
- }, 'image/png');
103
- };
104
- img.onerror = () => reject(new Error(`Failed to load SVG as image: ${url}`));
105
- img.src = `data:image/svg+xml;base64,${btoa(sized)}`;
106
- });
107
- cache.set(emoji, png);
108
- return png;
109
- };
110
- }
111
- // ── Pre-render helper ───────────────────────────────────────────────────────
112
- /**
113
- * Scans the full markdown text for all unique emoji and pre-renders them to
114
- * PNG `Buffer`s. The returned `Map` allows `renderTextWithEmoji` to stay
115
- * synchronous during rendering.
116
- *
117
- * @param text The raw markdown (or any text) to scan.
118
- * @param renderer A `ColorEmojiRenderer` (Node.js or browser factory).
119
- * @param emojiRe The emoji-matching regex (from `emoji.ts`).
120
- * @returns A `Map` from individual emoji string → PNG Buffer.
121
- */
122
- async function preRenderEmoji(text, renderer, emojiRe) {
123
- const unique = new Set();
124
- emojiRe.lastIndex = 0;
125
- let m;
126
- while ((m = emojiRe.exec(text)) !== null) {
127
- unique.add(m[0]);
128
- }
129
- const map = new Map();
130
- // Render all unique emoji in parallel
131
- await Promise.all([...unique].map(async (emoji) => {
132
- try {
133
- const png = await renderer(emoji);
134
- map.set(emoji, png);
135
- }
136
- catch {
137
- // If a specific emoji fails (missing from Twemoji), skip it —
138
- // it will fall back to the monochrome font or the body font.
139
- }
140
- }));
141
- return map;
142
- }
package/dist/emoji.d.ts DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * Emoji detection and text segmentation utilities.
3
- *
4
- * Splits a string into runs of "emoji" vs "non-emoji" characters so the
5
- * renderer can switch to an emoji font (e.g. Noto Emoji) for glyph coverage.
6
- */
7
- export interface TextSegment {
8
- text: string;
9
- isEmoji: boolean;
10
- }
11
- /**
12
- * Returns `true` when the whole string is emoji (one or more).
13
- */
14
- export declare function isEmoji(text: string): boolean;
15
- /**
16
- * Split `text` into contiguous runs of emoji and non-emoji characters.
17
- *
18
- * Example:
19
- * splitEmojiSegments("Hello 🎉🔥 world")
20
- * => [
21
- * { text: "Hello ", isEmoji: false },
22
- * { text: "🎉🔥", isEmoji: true },
23
- * { text: " world", isEmoji: false },
24
- * ]
25
- */
26
- export declare function splitEmojiSegments(text: string): TextSegment[];
27
- /**
28
- * Quick check: does the string contain at least one emoji?
29
- */
30
- export declare function containsEmoji(text: string): boolean;
31
- /**
32
- * Returns the module-level emoji regex. Useful for external callers
33
- * (e.g. `preRenderEmoji`) that need to scan text with the same pattern.
34
- */
35
- export declare function getEmojiRegex(): RegExp;
package/dist/emoji.js DELETED
@@ -1,137 +0,0 @@
1
- "use strict";
2
- /**
3
- * Emoji detection and text segmentation utilities.
4
- *
5
- * Splits a string into runs of "emoji" vs "non-emoji" characters so the
6
- * renderer can switch to an emoji font (e.g. Noto Emoji) for glyph coverage.
7
- */
8
- Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.isEmoji = isEmoji;
10
- exports.splitEmojiSegments = splitEmojiSegments;
11
- exports.containsEmoji = containsEmoji;
12
- exports.getEmojiRegex = getEmojiRegex;
13
- /**
14
- * Regex that matches a single emoji "unit".
15
- *
16
- * Covers:
17
- * - Emoji presentation sequences (base + U+FE0F)
18
- * - Keycap sequences (digit + U+FE0F + U+20E3)
19
- * - Flag sequences (regional indicators)
20
- * - ZWJ sequences (family, profession, etc.)
21
- * - Tag sequences (e.g. England flag)
22
- * - Modifier sequences (skin-tone)
23
- * - Standalone emoji codepoints in Emoticons, Dingbats, Symbols, etc.
24
- *
25
- * We use a wide net via Unicode property escapes where the runtime supports
26
- * them, falling back to explicit ranges for broad compatibility.
27
- */
28
- const EMOJI_RE = buildEmojiRegex();
29
- function buildEmojiRegex() {
30
- // Modern runtimes (Node 10+) support Unicode property escapes.
31
- // We try that first because it is maintained by the Unicode consortium
32
- // and stays current with new emoji releases.
33
- try {
34
- // \p{Emoji_Presentation} — characters rendered as emoji by default
35
- // \p{Emoji_Modifier_Base} — characters that accept skin-tone modifiers
36
- // The rest handles ZWJ, keycap, flag, and tag sequences.
37
- return new RegExp(
38
- // Regional indicator flag pairs — MUST come first so that
39
- // individual RI symbols are not consumed by the ZWJ / standalone
40
- // branches before they can pair up.
41
- '(?:[\\u{1F1E6}-\\u{1F1FF}]){2}' +
42
- '|' +
43
- // Tag sequences (e.g. flag of England)
44
- '\\u{1F3F4}[\\u{E0060}-\\u{E007E}]+\\u{E007F}' +
45
- '|' +
46
- // Keycap sequences: digit/# /* + VS16 + combining enclosing keycap
47
- '[0-9#*]\\uFE0F?\\u20E3' +
48
- '|' +
49
- // ZWJ sequences (family, professions, etc.)
50
- '(?:' +
51
- '(?:\\p{Emoji_Presentation}|\\p{Emoji_Modifier_Base})' +
52
- '(?:\\p{Emoji_Modifier})?' +
53
- '(?:\\u200D(?:\\p{Emoji_Presentation}|\\p{Emoji_Modifier_Base})(?:\\p{Emoji_Modifier})?)*)' +
54
- '|' +
55
- // Standalone emoji with optional VS16 — exclude RI range
56
- // (U+1F1E6–U+1F1FF) so lone indicators don't match here.
57
- '[\\u{1F000}-\\u{1F1E5}\\u{1F200}-\\u{1FAFF}\\u{2600}-\\u{27BF}\\u{2300}-\\u{23FF}\\u{2B50}\\u{2B55}\\u{FE00}-\\u{FE0F}\\u{200D}]\\uFE0F?' +
58
- '', 'gu');
59
- }
60
- catch {
61
- // Fallback: cover the most common emoji ranges without property escapes.
62
- return new RegExp('[' +
63
- '\\u{1F600}-\\u{1F64F}' + // Emoticons
64
- '\\u{1F300}-\\u{1F5FF}' + // Misc Symbols & Pictographs
65
- '\\u{1F680}-\\u{1F6FF}' + // Transport & Map
66
- '\\u{1F900}-\\u{1F9FF}' + // Supplemental Symbols
67
- '\\u{1FA00}-\\u{1FA6F}' + // Chess Symbols
68
- '\\u{1FA70}-\\u{1FAFF}' + // Symbols Extended-A
69
- '\\u{2600}-\\u{26FF}' + // Misc Symbols
70
- '\\u{2700}-\\u{27BF}' + // Dingbats
71
- '\\u{FE00}-\\u{FE0F}' + // Variation Selectors
72
- '\\u{200D}' + // ZWJ
73
- '\\u{1F1E6}-\\u{1F1FF}' + // Regional Indicators
74
- ']+', 'gu');
75
- }
76
- }
77
- /**
78
- * Returns `true` when the whole string is emoji (one or more).
79
- */
80
- function isEmoji(text) {
81
- const stripped = text.replace(EMOJI_RE, '');
82
- return stripped.trim().length === 0 && text.length > 0;
83
- }
84
- /**
85
- * Split `text` into contiguous runs of emoji and non-emoji characters.
86
- *
87
- * Example:
88
- * splitEmojiSegments("Hello 🎉🔥 world")
89
- * => [
90
- * { text: "Hello ", isEmoji: false },
91
- * { text: "🎉🔥", isEmoji: true },
92
- * { text: " world", isEmoji: false },
93
- * ]
94
- */
95
- function splitEmojiSegments(text) {
96
- if (!text)
97
- return [];
98
- const segments = [];
99
- let lastIndex = 0;
100
- // Reset global regex state
101
- EMOJI_RE.lastIndex = 0;
102
- let match;
103
- while ((match = EMOJI_RE.exec(text)) !== null) {
104
- // Push any non-emoji text before this match
105
- if (match.index > lastIndex) {
106
- segments.push({ text: text.slice(lastIndex, match.index), isEmoji: false });
107
- }
108
- // Merge consecutive emoji matches into one segment
109
- const prev = segments[segments.length - 1];
110
- if (prev && prev.isEmoji) {
111
- prev.text += match[0];
112
- }
113
- else {
114
- segments.push({ text: match[0], isEmoji: true });
115
- }
116
- lastIndex = EMOJI_RE.lastIndex;
117
- }
118
- // Trailing non-emoji text
119
- if (lastIndex < text.length) {
120
- segments.push({ text: text.slice(lastIndex), isEmoji: false });
121
- }
122
- return segments;
123
- }
124
- /**
125
- * Quick check: does the string contain at least one emoji?
126
- */
127
- function containsEmoji(text) {
128
- EMOJI_RE.lastIndex = 0;
129
- return EMOJI_RE.test(text);
130
- }
131
- /**
132
- * Returns the module-level emoji regex. Useful for external callers
133
- * (e.g. `preRenderEmoji`) that need to scan text with the same pattern.
134
- */
135
- function getEmojiRegex() {
136
- return EMOJI_RE;
137
- }
Binary file
@@ -1,15 +0,0 @@
1
- /**
2
- * Node.js color emoji renderer using `@resvg/resvg-js`.
3
- *
4
- * Separated from `color-emoji.ts` so that the browser entry point
5
- * (`src/browser.ts`) can import the shared helpers and browser factory
6
- * without pulling in the native `@resvg/resvg-js` addon.
7
- */
8
- import type { ColorEmojiRenderer } from './color-emoji.js';
9
- /**
10
- * Creates a color-emoji renderer for **Node.js** using `@resvg/resvg-js`.
11
- *
12
- * Fetches Twemoji SVGs over HTTPS on first use and caches the resulting
13
- * PNGs for the lifetime of the returned function.
14
- */
15
- export declare function createNodeColorEmojiRenderer(): ColorEmojiRenderer;
@@ -1,57 +0,0 @@
1
- "use strict";
2
- /**
3
- * Node.js color emoji renderer using `@resvg/resvg-js`.
4
- *
5
- * Separated from `color-emoji.ts` so that the browser entry point
6
- * (`src/browser.ts`) can import the shared helpers and browser factory
7
- * without pulling in the native `@resvg/resvg-js` addon.
8
- */
9
- Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.createNodeColorEmojiRenderer = createNodeColorEmojiRenderer;
11
- const color_emoji_js_1 = require("./color-emoji.js");
12
- /**
13
- * Creates a color-emoji renderer for **Node.js** using `@resvg/resvg-js`.
14
- *
15
- * Fetches Twemoji SVGs over HTTPS on first use and caches the resulting
16
- * PNGs for the lifetime of the returned function.
17
- */
18
- function createNodeColorEmojiRenderer() {
19
- const cache = new Map();
20
- // eslint-disable-next-line @typescript-eslint/no-require-imports
21
- const { Resvg } = require('@resvg/resvg-js');
22
- function fetchSvg(url) {
23
- return new Promise((resolve, reject) => {
24
- // eslint-disable-next-line @typescript-eslint/no-require-imports
25
- const mod = url.startsWith('https') ? require('https') : require('http');
26
- mod.get(url, (res) => {
27
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
28
- res.resume();
29
- fetchSvg(res.headers.location).then(resolve, reject);
30
- return;
31
- }
32
- if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
33
- res.resume();
34
- reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
35
- return;
36
- }
37
- const chunks = [];
38
- res.on('data', (c) => chunks.push(c));
39
- res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
40
- res.on('error', reject);
41
- }).on('error', reject);
42
- });
43
- }
44
- return async (emoji) => {
45
- const hit = cache.get(emoji);
46
- if (hit)
47
- return hit;
48
- const svg = await fetchSvg((0, color_emoji_js_1.twemojiSvgUrl)(emoji));
49
- const sized = (0, color_emoji_js_1.sizeSvg)(svg, color_emoji_js_1.EMOJI_RENDER_SIZE);
50
- const resvg = new Resvg(Buffer.from(sized), {
51
- fitTo: { mode: 'width', value: color_emoji_js_1.EMOJI_RENDER_SIZE },
52
- });
53
- const png = Buffer.from(resvg.render().asPng());
54
- cache.set(emoji, png);
55
- return png;
56
- };
57
- }