@speajus/markdown-to-pdf 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ A lightweight TypeScript library that converts Markdown files into styled PDF do
19
19
  ## Installation
20
20
 
21
21
  ```bash
22
- npm install
22
+ npm install @speajus/markdown-to-pdf
23
23
  ```
24
24
 
25
25
  ## Quick Start
@@ -29,32 +29,39 @@ npm install
29
29
  Convert a Markdown file to PDF from the command line:
30
30
 
31
31
  ```bash
32
- npx ts-node src/cli.ts <input.md> [output.pdf]
32
+ # Run directly with npx (no install needed)
33
+ npx @speajus/markdown-to-pdf <input.md> [output.pdf]
34
+
35
+ # Or if installed globally / as a project dependency
36
+ markdown-to-pdf <input.md> [output.pdf]
33
37
  ```
34
38
 
35
39
  If the output path is omitted, the PDF is written alongside the input file with a `.pdf` extension.
36
40
 
37
41
  ```bash
38
42
  # Converts README.md → README.pdf
39
- npx ts-node src/cli.ts README.md
43
+ npx @speajus/markdown-to-pdf README.md
40
44
 
41
45
  # Explicit output path
42
- npx ts-node src/cli.ts docs/report.md output/report.pdf
46
+ npx @speajus/markdown-to-pdf docs/report.md output/report.pdf
43
47
  ```
44
48
 
45
49
  ### Programmatic API
46
50
 
47
51
  ```typescript
48
- import { generatePdf } from './src/index';
52
+ import { generatePdf } from '@speajus/markdown-to-pdf';
49
53
 
50
- // File-based reads Markdown from disk, writes PDF to disk
51
- await generatePdf('samples/sample.md', 'output/sample.pdf');
54
+ // Returns a PDF Buffer
55
+ const markdown = '# Hello World\n\nThis is a **test**.';
56
+ const pdfBuffer = await generatePdf(markdown);
52
57
 
53
- // Buffer-based — returns a PDF Buffer for further processing
54
- import { renderMarkdownToPdf } from './src/index';
58
+ // With options
59
+ import { generatePdf, createNodeImageRenderer } from '@speajus/markdown-to-pdf';
55
60
 
56
- const markdown = '# Hello World\n\nThis is a **test**.';
57
- const pdfBuffer = await renderMarkdownToPdf(markdown);
61
+ const buffer = await generatePdf(markdown, {
62
+ basePath: '/path/to/markdown/directory',
63
+ renderImage: createNodeImageRenderer('/path/to/markdown/directory'),
64
+ });
58
65
  ```
59
66
 
60
67
  ### Generate Sample PDFs
@@ -82,9 +89,9 @@ interface PdfOptions {
82
89
  ### Page Layout
83
90
 
84
91
  ```typescript
85
- import { generatePdf } from './src/index';
92
+ import { generatePdf } from '@speajus/markdown-to-pdf';
86
93
 
87
- await generatePdf('input.md', 'output.pdf', {
94
+ await generatePdf(markdown, {
88
95
  pageLayout: {
89
96
  pageSize: 'A4',
90
97
  margins: { top: 72, right: 72, bottom: 72, left: 72 },
@@ -99,9 +106,9 @@ The default layout uses **Letter** page size with 50pt margins on all sides.
99
106
  Override any part of the default theme to customize the look of the generated PDF:
100
107
 
101
108
  ```typescript
102
- import { generatePdf, defaultTheme } from './src/index';
109
+ import { generatePdf, defaultTheme } from '@speajus/markdown-to-pdf';
103
110
 
104
- await generatePdf('input.md', 'output.pdf', {
111
+ await generatePdf(markdown, {
105
112
  theme: {
106
113
  ...defaultTheme,
107
114
  headings: {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Creates a browser-based image renderer that supports:
3
+ * - Remote images (http/https) via fetch with <img> + canvas fallback
4
+ * - Data URLs
5
+ * - Blob URLs
6
+ *
7
+ * SVG images are automatically converted to PNG via Canvas rendering using a
8
+ * same-origin blob URL so the canvas is never tainted.
9
+ *
10
+ * @returns A function that takes an image URL and returns a Buffer
11
+ */
12
+ export declare const createBrowserImageRenderer: (basePath: string) => (imageUrl: string) => Promise<Buffer<ArrayBufferLike>>;
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBrowserImageRenderer = void 0;
4
+ const defaults_js_1 = require("./defaults.js");
5
+ /**
6
+ * Browser-based image renderer using Canvas API and Fetch API.
7
+ * This implementation works in browser environments where Node.js APIs are not available.
8
+ */
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+ /**
11
+ * Path prefix for the image proxy endpoint.
12
+ * When running behind a dev server (e.g. Vite) that exposes this endpoint,
13
+ * remote image URLs are rewritten to go through the proxy so that the
14
+ * browser fetch is same-origin and avoids CORS restrictions.
15
+ */
16
+ const IMAGE_PROXY_PREFIX = '/__image_proxy/';
17
+ /**
18
+ * Fetches an image using the Fetch API and returns the raw bytes as a Buffer.
19
+ * Works for http/https URLs, blob URLs, and data URLs in modern browsers.
20
+ *
21
+ * @param url - Image URL to fetch
22
+ * @returns A Buffer containing the image data
23
+ */
24
+ async function fetchImageInBrowser(url) {
25
+ const controller = new AbortController();
26
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
27
+ try {
28
+ const response = await fetch(url, { signal: controller.signal });
29
+ if (!response.ok) {
30
+ throw new Error(`HTTP ${response.status} fetching ${url}`);
31
+ }
32
+ const arrayBuffer = await response.arrayBuffer();
33
+ return Buffer.from(arrayBuffer);
34
+ }
35
+ catch (err) {
36
+ if (err instanceof Error && err.name === 'AbortError') {
37
+ throw new Error(`Timeout fetching ${url} after ${FETCH_TIMEOUT_MS}ms`);
38
+ }
39
+ throw err;
40
+ }
41
+ finally {
42
+ clearTimeout(timeoutId);
43
+ }
44
+ }
45
+ /**
46
+ * Converts an SVG buffer to PNG by rendering it onto a canvas.
47
+ * This is necessary because pdfkit doesn't support SVG natively.
48
+ *
49
+ * @param svgBuffer - Buffer containing SVG data
50
+ * @returns A Buffer containing the PNG image data
51
+ */
52
+ function renderSvgToPng(svgBuffer) {
53
+ return new Promise((resolve, reject) => {
54
+ const svgBlob = new Blob([new Uint8Array(svgBuffer)], { type: 'image/svg+xml' });
55
+ const url = URL.createObjectURL(svgBlob);
56
+ const img = new Image();
57
+ const timeoutId = setTimeout(() => {
58
+ URL.revokeObjectURL(url);
59
+ reject(new Error(`Timeout rendering SVG after ${FETCH_TIMEOUT_MS}ms`));
60
+ }, FETCH_TIMEOUT_MS);
61
+ img.onload = () => {
62
+ clearTimeout(timeoutId);
63
+ try {
64
+ const canvas = document.createElement('canvas');
65
+ canvas.width = img.naturalWidth || img.width || 300;
66
+ canvas.height = img.naturalHeight || img.height || 150;
67
+ const ctx = canvas.getContext('2d');
68
+ if (!ctx) {
69
+ reject(new Error('Failed to get canvas context'));
70
+ URL.revokeObjectURL(url);
71
+ return;
72
+ }
73
+ ctx.drawImage(img, 0, 0);
74
+ URL.revokeObjectURL(url);
75
+ canvas.toBlob((blob) => {
76
+ if (!blob) {
77
+ reject(new Error('Failed to convert SVG canvas to blob'));
78
+ return;
79
+ }
80
+ const reader = new FileReader();
81
+ reader.onload = () => resolve(Buffer.from(reader.result));
82
+ reader.onerror = () => reject(new Error('Failed to read SVG blob'));
83
+ reader.readAsArrayBuffer(blob);
84
+ }, 'image/png');
85
+ }
86
+ catch (err) {
87
+ URL.revokeObjectURL(url);
88
+ reject(err);
89
+ }
90
+ };
91
+ img.onerror = () => {
92
+ clearTimeout(timeoutId);
93
+ URL.revokeObjectURL(url);
94
+ reject(new Error('Failed to load SVG for rendering'));
95
+ };
96
+ img.src = url;
97
+ });
98
+ }
99
+ function isSvg(buf) {
100
+ const head = buf.subarray(0, 256).toString('utf-8').trimStart();
101
+ return head.startsWith('<svg') || head.startsWith('<?xml');
102
+ }
103
+ /**
104
+ * Loads a cross-origin image via an <img> element with crossOrigin="anonymous",
105
+ * draws it to a canvas, and exports as PNG. This works when the server supports
106
+ * CORS for <img> requests (many CDNs do) even if fetch() was blocked.
107
+ *
108
+ * @param imageUrl - URL of the image
109
+ * @returns A Buffer containing PNG image data
110
+ */
111
+ function loadImageViaCanvas(imageUrl) {
112
+ return new Promise((resolve, reject) => {
113
+ const img = new Image();
114
+ img.crossOrigin = 'anonymous';
115
+ const timeoutId = setTimeout(() => {
116
+ reject(new Error(`Timeout loading image: ${imageUrl} after ${FETCH_TIMEOUT_MS}ms`));
117
+ }, FETCH_TIMEOUT_MS);
118
+ img.onload = () => {
119
+ clearTimeout(timeoutId);
120
+ try {
121
+ const canvas = document.createElement('canvas');
122
+ canvas.width = img.naturalWidth || img.width;
123
+ canvas.height = img.naturalHeight || img.height;
124
+ const ctx = canvas.getContext('2d');
125
+ if (!ctx) {
126
+ reject(new Error('Failed to get canvas context'));
127
+ return;
128
+ }
129
+ ctx.drawImage(img, 0, 0);
130
+ canvas.toBlob((blob) => {
131
+ if (!blob) {
132
+ reject(new Error('Failed to convert canvas to blob'));
133
+ return;
134
+ }
135
+ const reader = new FileReader();
136
+ reader.onload = () => resolve(Buffer.from(reader.result));
137
+ reader.onerror = () => reject(new Error('Failed to read blob'));
138
+ reader.readAsArrayBuffer(blob);
139
+ }, 'image/png');
140
+ }
141
+ catch (err) {
142
+ reject(err);
143
+ }
144
+ };
145
+ img.onerror = () => {
146
+ clearTimeout(timeoutId);
147
+ reject(new Error(`Failed to load image: ${imageUrl}. The server may not support CORS. ` +
148
+ `To fix this, either serve the image from the same origin, use a CORS proxy, ` +
149
+ `or convert it to a data URL before passing it to the renderer.`));
150
+ };
151
+ img.src = imageUrl;
152
+ });
153
+ }
154
+ /**
155
+ * Loads an image in the browser and returns it as a Buffer.
156
+ *
157
+ * Strategy:
158
+ * 1. Try fetch() — works for same-origin, data/blob URLs, and CORS-enabled servers.
159
+ * 2. If fetch fails (CORS), fall back to <img crossOrigin="anonymous"> + canvas,
160
+ * which works for servers that support CORS on image requests.
161
+ * 3. If both fail, throw a descriptive error with remediation steps.
162
+ *
163
+ * SVG images are rasterized to PNG via a same-origin blob URL on a canvas.
164
+ *
165
+ * @param basePath - Base path (unused in browser, kept for API compatibility)
166
+ * @param imageUrl - URL of the image (http/https/data/blob URL)
167
+ * @returns A Buffer containing the image data
168
+ */
169
+ async function loadImageInBrowser(_basePath, imageUrl) {
170
+ // For remote URLs, try the same-origin image proxy first (avoids CORS entirely).
171
+ const isRemote = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
172
+ const proxyUrl = isRemote
173
+ ? `${IMAGE_PROXY_PREFIX}${encodeURIComponent(imageUrl)}`
174
+ : imageUrl;
175
+ try {
176
+ const imgBuffer = await fetchImageInBrowser(proxyUrl);
177
+ // SVGs must be rasterized to PNG for pdfkit
178
+ if (isSvg(imgBuffer)) {
179
+ return renderSvgToPng(imgBuffer);
180
+ }
181
+ return imgBuffer;
182
+ }
183
+ catch {
184
+ // Proxy or fetch failed — fall back to <img crossOrigin="anonymous"> + canvas.
185
+ // This still works when the remote server sends CORS headers for <img> requests.
186
+ return loadImageViaCanvas(imageUrl);
187
+ }
188
+ }
189
+ /**
190
+ * Creates a browser-based image renderer that supports:
191
+ * - Remote images (http/https) via fetch with <img> + canvas fallback
192
+ * - Data URLs
193
+ * - Blob URLs
194
+ *
195
+ * SVG images are automatically converted to PNG via Canvas rendering using a
196
+ * same-origin blob URL so the canvas is never tainted.
197
+ *
198
+ * @returns A function that takes an image URL and returns a Buffer
199
+ */
200
+ const createBrowserImageRenderer = (basePath) => loadImageInBrowser.bind(null, basePath);
201
+ exports.createBrowserImageRenderer = createBrowserImageRenderer;
202
+ defaults_js_1.DEFAULTS.renderImage = exports.createBrowserImageRenderer;
@@ -0,0 +1,6 @@
1
+ export { createBrowserImageRenderer } from "./browser-image-renderer.js";
2
+ export { createBrowserColorEmojiRenderer } from './color-emoji.js';
3
+ export type { TextStyle, PageLayout, CodeStyle, CodeBlockStyle, BlockquoteStyle, TableStyles, TokenColors, SyntaxHighlightTheme, ThemeConfig, PdfOptions, ColorEmojiRenderer, } from './types.js';
4
+ export { defaultTheme, defaultPageLayout, defaultSyntaxHighlightTheme } from './styles.js';
5
+ export { themes, modernTheme, academicTheme, minimalTheme, oceanTheme } from './themes/index.js';
6
+ export { renderMarkdownToPdf } from "./renderer.js";
@@ -0,0 +1,19 @@
1
+ "use strict";
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;
4
+ var browser_image_renderer_js_1 = require("./browser-image-renderer.js");
5
+ Object.defineProperty(exports, "createBrowserImageRenderer", { enumerable: true, get: function () { return browser_image_renderer_js_1.createBrowserImageRenderer; } });
6
+ var color_emoji_js_1 = require("./color-emoji.js");
7
+ Object.defineProperty(exports, "createBrowserColorEmojiRenderer", { enumerable: true, get: function () { return color_emoji_js_1.createBrowserColorEmojiRenderer; } });
8
+ var styles_js_1 = require("./styles.js");
9
+ Object.defineProperty(exports, "defaultTheme", { enumerable: true, get: function () { return styles_js_1.defaultTheme; } });
10
+ Object.defineProperty(exports, "defaultPageLayout", { enumerable: true, get: function () { return styles_js_1.defaultPageLayout; } });
11
+ Object.defineProperty(exports, "defaultSyntaxHighlightTheme", { enumerable: true, get: function () { return styles_js_1.defaultSyntaxHighlightTheme; } });
12
+ var index_js_1 = require("./themes/index.js");
13
+ Object.defineProperty(exports, "themes", { enumerable: true, get: function () { return index_js_1.themes; } });
14
+ Object.defineProperty(exports, "modernTheme", { enumerable: true, get: function () { return index_js_1.modernTheme; } });
15
+ Object.defineProperty(exports, "academicTheme", { enumerable: true, get: function () { return index_js_1.academicTheme; } });
16
+ Object.defineProperty(exports, "minimalTheme", { enumerable: true, get: function () { return index_js_1.minimalTheme; } });
17
+ Object.defineProperty(exports, "oceanTheme", { enumerable: true, get: function () { return index_js_1.oceanTheme; } });
18
+ var renderer_js_1 = require("./renderer.js");
19
+ Object.defineProperty(exports, "renderMarkdownToPdf", { enumerable: true, get: function () { return renderer_js_1.renderMarkdownToPdf; } });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import type { PdfOptions } from './types.js';
3
+ export declare function readMdWritePdf(inputPath: string, outputPath: string, extraOptions?: Partial<PdfOptions>): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.readMdWritePdf = readMdWritePdf;
8
+ const path_1 = __importDefault(require("path"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const index_js_1 = require("./index.js");
11
+ async function readMdWritePdf(inputPath, outputPath, extraOptions) {
12
+ console.log(`Converting ${inputPath} → ${outputPath}`);
13
+ const resolvedInput = path_1.default.resolve(inputPath);
14
+ const markdown = fs_1.default.readFileSync(resolvedInput, "utf-8");
15
+ const basePath = path_1.default.dirname(resolvedInput);
16
+ // Use Node.js image renderer with the basePath
17
+ const renderImage = (0, index_js_1.createNodeImageRenderer)(basePath);
18
+ const buffer = await (0, index_js_1.generatePdf)(markdown, { basePath, renderImage, ...extraOptions });
19
+ const dir = path_1.default.dirname(path_1.default.resolve(outputPath));
20
+ if (!fs_1.default.existsSync(dir))
21
+ fs_1.default.mkdirSync(dir, { recursive: true });
22
+ fs_1.default.writeFileSync(path_1.default.resolve(outputPath), buffer);
23
+ console.log(`Done. PDF written to ${path_1.default.resolve(outputPath)}`);
24
+ }
25
+ async function main() {
26
+ const args = process.argv.slice(2);
27
+ if (args.length === 0) {
28
+ console.error('Usage: markdown-to-pdf <input.md> [output.pdf]');
29
+ process.exit(1);
30
+ }
31
+ const inputPath = args[0];
32
+ const outputPath = args[1] || inputPath.replace(/\.md$/i, '.pdf');
33
+ readMdWritePdf(inputPath, outputPath);
34
+ }
35
+ if (require.main === module) {
36
+ main().catch((err) => {
37
+ console.error('Error:', err);
38
+ process.exit(1);
39
+ });
40
+ }
@@ -0,0 +1,43 @@
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>>;
@@ -0,0 +1,142 @@
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
+ }
@@ -0,0 +1,3 @@
1
+ export declare const DEFAULTS: {
2
+ renderImage: (basePath: string) => (imageUrl: string) => Promise<Buffer>;
3
+ };
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULTS = void 0;
4
+ exports.DEFAULTS = {};
@@ -0,0 +1,35 @@
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;