@speajus/markdown-to-pdf 1.0.15 → 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/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
@@ -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,61 @@ 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'
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 !== '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
+ }
66
69
  if (nodeFs.existsSync(fontPath)) {
67
- doc.registerFont(EMOJI_FONT_NAME, fontPath);
68
- emojiEnabled = true;
70
+ resolvedEmojiFont = fontPath;
69
71
  }
70
72
  }
71
73
  }
72
74
  catch {
73
- // Font registration failed or Node.js APIs not available (browser) —
74
- // fall through without emoji support.
75
+ // Node.js APIs not available (browser) — no emoji font.
75
76
  }
76
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));
77
87
  // ── Custom font registration ─────────────────────────────────────────────
78
88
  // Register user-supplied font families so they can be referenced by name
79
89
  // in ThemeConfig font fields. Each variant is registered with a suffix:
@@ -108,15 +118,8 @@ async function renderMarkdownToPdf(markdown, options) {
108
118
  catch {
109
119
  // Node.js `fs` not available — we're in a browser environment.
110
120
  }
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
- }
121
+ // ── Spacing config ────────────────────────────────────────────────────
122
+ const sp = { ...styles_js_1.defaultSpacing, ...theme.spacing };
120
123
  const tokens = marked_1.marked.lexer(markdown);
121
124
  const contentWidth = doc.page.width - margins.left - margins.right;
122
125
  // ── Table cell context ──────────────────────────────────────────────────
@@ -257,20 +260,16 @@ async function renderMarkdownToPdf(markdown, options) {
257
260
  doc.font(safeFont(theme.body.font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
258
261
  }
259
262
  /**
260
- * Render a text string switching to the emoji font for emoji characters.
261
- *
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.
263
+ * Render text, delegating to `doc.text()`.
265
264
  *
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.
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.
269
268
  *
270
- * Supports both `renderTextWithEmoji(text, opts?)` and the positioned
271
- * 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.
272
271
  */
273
- function renderTextWithEmoji(text, xOrOpts, yOrUndefined, posOpts) {
272
+ function renderText(text, xOrOpts, yOrUndefined, posOpts) {
274
273
  // Normalise the two call signatures into a single (opts, firstX, firstY).
275
274
  let opts;
276
275
  let firstX;
@@ -290,163 +289,12 @@ async function renderMarkdownToPdf(markdown, options) {
290
289
  opts = { ...opts, width: cellCtx.width, align: cellCtx.align };
291
290
  cellCtx.used = true;
292
291
  }
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;
303
- }
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;
292
+ if (firstX !== undefined) {
293
+ doc.text(text, firstX, firstY, opts);
428
294
  }
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
- }
295
+ else {
296
+ doc.text(text, opts);
447
297
  }
448
- // Restore the original font so subsequent calls aren't surprised.
449
- doc.font(prevFont).fontSize(prevSize);
450
298
  }
451
299
  function renderCodespan(text, continued) {
452
300
  const cs = theme.code.inline;
@@ -504,7 +352,7 @@ async function renderMarkdownToPdf(markdown, options) {
504
352
  doc.font(safeFont(theme.body.font)).fontSize(theme.body.fontSize).fillColor(theme.linkColor);
505
353
  }
506
354
  const linkText = tok.text || tok.href;
507
- renderTextWithEmoji(linkText, { continued, underline: true, link: tok.href });
355
+ renderText(linkText, { continued, underline: true, link: tok.href });
508
356
  doc.fillColor(headingCtx ? headingCtx.color : theme.body.color);
509
357
  }
510
358
  async function renderInlineTokens(inlineTokens, continued, insideBold = false, insideItalic = false) {
@@ -520,7 +368,7 @@ async function renderMarkdownToPdf(markdown, options) {
520
368
  }
521
369
  else {
522
370
  applyBodyFont(insideBold, insideItalic);
523
- renderTextWithEmoji(t.text, { continued: cont, underline: false, strike: false });
371
+ renderText(t.text, { continued: cont, underline: false, strike: false });
524
372
  }
525
373
  break;
526
374
  }
@@ -548,12 +396,12 @@ async function renderMarkdownToPdf(markdown, options) {
548
396
  }
549
397
  case 'del': {
550
398
  applyBodyFont(insideBold, insideItalic);
551
- renderTextWithEmoji(tok.text, { continued: cont, strike: true, underline: false });
399
+ renderText(tok.text, { continued: cont, strike: true, underline: false });
552
400
  break;
553
401
  }
554
402
  case 'escape': {
555
403
  applyBodyFont(insideBold, insideItalic);
556
- renderTextWithEmoji(tok.text, { continued: cont, underline: false, strike: false });
404
+ renderText(tok.text, { continued: cont, underline: false, strike: false });
557
405
  break;
558
406
  }
559
407
  case 'br': {
@@ -564,7 +412,7 @@ async function renderMarkdownToPdf(markdown, options) {
564
412
  const raw = tok.text ?? tok.raw ?? '';
565
413
  if (raw) {
566
414
  applyBodyFont(insideBold, insideItalic);
567
- renderTextWithEmoji(raw, { continued: cont, underline: false, strike: false });
415
+ renderText(raw, { continued: cont, underline: false, strike: false });
568
416
  }
569
417
  break;
570
418
  }
@@ -588,9 +436,12 @@ async function renderMarkdownToPdf(markdown, options) {
588
436
  displayWidth = img.width * (displayHeight / img.height);
589
437
  }
590
438
  ensureSpace(displayHeight + 10);
591
- 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;
592
443
  const imgY = doc.y;
593
- doc.image(imgBuffer, { width: displayWidth, height: displayHeight });
444
+ doc.image(imgBuffer, imgX, imgY, { width: displayWidth, height: displayHeight });
594
445
  // If the image is wrapped in a link, overlay a clickable annotation
595
446
  if (linkUrl) {
596
447
  doc.link(imgX, imgY, displayWidth, displayHeight, linkUrl);
@@ -605,13 +456,13 @@ async function renderMarkdownToPdf(markdown, options) {
605
456
  }
606
457
  }
607
458
  async function renderList(list, depth) {
608
- const indent = margins.left + depth * 20;
459
+ const indent = margins.left + depth * sp.listIndent;
609
460
  for (let idx = 0; idx < list.items.length; idx++) {
610
461
  const item = list.items[idx];
611
462
  ensureSpace(theme.body.fontSize * 2);
612
463
  resetBodyFont();
613
464
  const bullet = list.ordered ? `${list.start + idx}.` : '•';
614
- 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 });
615
466
  doc.text(' ', { continued: true });
616
467
  // Render item inline tokens
617
468
  const itemTokens = item.tokens;
@@ -622,7 +473,7 @@ async function renderMarkdownToPdf(markdown, options) {
622
473
  await renderInlineTokens(t.tokens, false);
623
474
  }
624
475
  else {
625
- renderTextWithEmoji(t.text);
476
+ renderText(t.text);
626
477
  }
627
478
  }
628
479
  else if (child.type === 'paragraph') {
@@ -632,7 +483,7 @@ async function renderMarkdownToPdf(markdown, options) {
632
483
  await renderList(child, depth + 1);
633
484
  }
634
485
  }
635
- doc.moveDown(0.2);
486
+ doc.moveDown(sp.listItemSpacing);
636
487
  }
637
488
  }
638
489
  async function renderCellTokens(cell, x, y, width, align, bold) {
@@ -646,7 +497,7 @@ async function renderMarkdownToPdf(markdown, options) {
646
497
  else {
647
498
  // Fallback: plain text (no inline tokens)
648
499
  applyBodyFont(bold, false);
649
- renderTextWithEmoji(cell.text, x, y, { width, align });
500
+ renderText(cell.text, x, y, { width, align });
650
501
  }
651
502
  doc.y = savedY;
652
503
  }
@@ -715,8 +566,8 @@ async function renderMarkdownToPdf(markdown, options) {
715
566
  const t = token;
716
567
  const key = `h${t.depth}`;
717
568
  const style = theme.headings[key];
718
- const spaceAbove = style.fontSize * 0.8;
719
- const spaceBelow = style.fontSize * 0.3;
569
+ const spaceAbove = style.fontSize * sp.headingSpaceAbove;
570
+ const spaceBelow = style.fontSize * sp.headingSpaceBelow;
720
571
  ensureSpace(spaceAbove + style.fontSize + spaceBelow);
721
572
  doc.moveDown(spaceAbove / doc.currentLineHeight());
722
573
  doc.font(safeFont(style.font)).fontSize(style.fontSize).fillColor(style.color);
@@ -725,7 +576,7 @@ async function renderMarkdownToPdf(markdown, options) {
725
576
  await renderInlineTokens(t.tokens, false, style.bold ?? false, style.italic ?? false);
726
577
  }
727
578
  else {
728
- renderTextWithEmoji(t.text);
579
+ renderText(t.text);
729
580
  }
730
581
  headingCtx = null;
731
582
  // Draw an underline beneath h1 and h2
@@ -748,7 +599,7 @@ async function renderMarkdownToPdf(markdown, options) {
748
599
  ensureSpace(theme.body.fontSize * 2);
749
600
  resetBodyFont();
750
601
  await renderInlineTokens(t.tokens, false);
751
- doc.moveDown(0.5);
602
+ doc.moveDown(sp.paragraphSpacing);
752
603
  break;
753
604
  }
754
605
  case 'code': {
@@ -772,10 +623,11 @@ async function renderMarkdownToPdf(markdown, options) {
772
623
  lineNumbers,
773
624
  drawBackground: true,
774
625
  theme: theme.syntaxHighlight,
626
+ borderRadius: cs.borderRadius,
775
627
  });
776
628
  doc.x = margins.left;
777
629
  doc.y = newY;
778
- doc.moveDown(0.5);
630
+ doc.moveDown(sp.codeBlockSpacing);
779
631
  resetBodyFont();
780
632
  }
781
633
  else {
@@ -786,8 +638,14 @@ async function renderMarkdownToPdf(markdown, options) {
786
638
  ensureSpace(blockH + 10);
787
639
  const x = margins.left;
788
640
  const y = doc.y;
641
+ const cbRadius = cs.borderRadius ?? 0;
789
642
  doc.save();
790
- 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
+ }
791
649
  doc.restore();
792
650
  doc.font(safeFont(cs.font)).fontSize(cs.fontSize).fillColor(cs.color);
793
651
  let textY = y + cs.padding;
@@ -797,7 +655,7 @@ async function renderMarkdownToPdf(markdown, options) {
797
655
  }
798
656
  doc.x = margins.left;
799
657
  doc.y = y + blockH;
800
- doc.moveDown(0.5);
658
+ doc.moveDown(sp.codeBlockSpacing);
801
659
  resetBodyFont();
802
660
  }
803
661
  break;
@@ -806,7 +664,7 @@ async function renderMarkdownToPdf(markdown, options) {
806
664
  const t = token;
807
665
  const bq = theme.blockquote;
808
666
  ensureSpace(30);
809
- const bqPadding = 6; // vertical padding above and below text
667
+ const bqPadding = bq.padding ?? 6;
810
668
  const startY = doc.y;
811
669
  doc.y += bqPadding; // add top padding before text
812
670
  const textX = margins.left + bq.borderWidth + bq.indent;
@@ -818,7 +676,7 @@ async function renderMarkdownToPdf(markdown, options) {
818
676
  doc.font(safeFont(font)).fontSize(theme.body.fontSize).fillColor(theme.body.color);
819
677
  doc.text('', textX, doc.y, { width: textWidth });
820
678
  await renderInlineTokens(p.tokens, false, false, bq.italic);
821
- doc.moveDown(0.3);
679
+ doc.moveDown(sp.blockquoteSpacing);
822
680
  }
823
681
  else {
824
682
  await renderToken(child);
@@ -826,11 +684,18 @@ async function renderMarkdownToPdf(markdown, options) {
826
684
  }
827
685
  doc.y += bqPadding; // add bottom padding after text
828
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
829
694
  doc.save();
830
695
  doc.rect(margins.left, startY, bq.borderWidth, endY - startY).fill(bq.borderColor);
831
696
  doc.restore();
832
697
  doc.x = margins.left;
833
- doc.moveDown(0.3);
698
+ doc.moveDown(sp.blockquoteSpacing);
834
699
  resetBodyFont();
835
700
  break;
836
701
  }
@@ -841,7 +706,7 @@ async function renderMarkdownToPdf(markdown, options) {
841
706
  }
842
707
  case 'hr': {
843
708
  ensureSpace(20);
844
- doc.moveDown(0.5);
709
+ doc.moveDown(sp.hrSpacing);
845
710
  const y = doc.y;
846
711
  doc.save();
847
712
  doc.strokeColor(theme.horizontalRuleColor).lineWidth(1)
@@ -850,7 +715,7 @@ async function renderMarkdownToPdf(markdown, options) {
850
715
  .stroke();
851
716
  doc.restore();
852
717
  doc.y = y;
853
- doc.moveDown(0.5);
718
+ doc.moveDown(sp.hrSpacing);
854
719
  resetBodyFont();
855
720
  break;
856
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;
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,16 @@ 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
+ * - `'none'` — disable emoji font; emoji render with the body font.
96
+ */
97
+ emojiFont?: 'twemoji' | 'openmoji' | 'none';
75
98
  }
76
99
  /**
77
100
  * A custom font definition providing font data for registration with PDFKit.
@@ -92,8 +115,6 @@ export interface CustomFontDefinition {
92
115
  /** Bold-italic variant (falls back to bold or regular). */
93
116
  boldItalic?: Buffer;
94
117
  }
95
- /** Converts a single emoji string to a PNG `Buffer`. */
96
- export type ColorEmojiRenderer = (emoji: string) => Promise<Buffer>;
97
118
  export interface PdfOptions {
98
119
  theme?: ThemeConfig;
99
120
  pageLayout?: PageLayout;
@@ -151,20 +172,6 @@ export interface PdfOptions {
151
172
  * @default true
152
173
  */
153
174
  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
175
  /**
169
176
  * Custom font definitions to register with PDFKit.
170
177
  *
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.16",
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",
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
- }