@speajus/markdown-to-pdf 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -15
- package/dist/browser-image-renderer.d.ts +12 -0
- package/dist/browser-image-renderer.js +202 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +19 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +40 -0
- package/dist/color-emoji.d.ts +43 -0
- package/dist/color-emoji.js +142 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.js +4 -0
- package/dist/emoji.d.ts +35 -0
- package/dist/emoji.js +137 -0
- package/dist/highlight.prism.d.ts +76 -0
- package/dist/highlight.prism.js +227 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +20 -0
- package/dist/node-color-emoji.d.ts +15 -0
- package/dist/node-color-emoji.js +57 -0
- package/dist/node-image-renderer.d.ts +10 -0
- package/dist/node-image-renderer.js +81 -0
- package/dist/renderer.d.ts +2 -0
- package/dist/renderer.js +644 -0
- package/dist/styles.d.ts +5 -0
- package/dist/styles.js +92 -0
- package/dist/themes/index.d.ts +12 -0
- package/dist/themes/index.js +158 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.js +2 -0
- package/package.json +12 -9
- /package/dist/{src/fonts → fonts}/NotoEmoji-Regular.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-Bold.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-BoldItalic.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-Italic.ttf +0 -0
- /package/dist/{src/fonts → fonts}/NotoSans-Regular.ttf +0 -0
package/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
|
|
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
|
|
43
|
+
npx @speajus/markdown-to-pdf README.md
|
|
40
44
|
|
|
41
45
|
# Explicit output path
|
|
42
|
-
npx
|
|
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 '
|
|
52
|
+
import { generatePdf } from '@speajus/markdown-to-pdf';
|
|
49
53
|
|
|
50
|
-
//
|
|
51
|
-
|
|
54
|
+
// Returns a PDF Buffer
|
|
55
|
+
const markdown = '# Hello World\n\nThis is a **test**.';
|
|
56
|
+
const pdfBuffer = await generatePdf(markdown);
|
|
52
57
|
|
|
53
|
-
//
|
|
54
|
-
import {
|
|
58
|
+
// With options
|
|
59
|
+
import { generatePdf, createNodeImageRenderer } from '@speajus/markdown-to-pdf';
|
|
55
60
|
|
|
56
|
-
const
|
|
57
|
-
|
|
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 '
|
|
92
|
+
import { generatePdf } from '@speajus/markdown-to-pdf';
|
|
86
93
|
|
|
87
|
-
await generatePdf(
|
|
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 '
|
|
109
|
+
import { generatePdf, defaultTheme } from '@speajus/markdown-to-pdf';
|
|
103
110
|
|
|
104
|
-
await generatePdf(
|
|
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";
|
package/dist/browser.js
ADDED
|
@@ -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
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
|
+
}
|
package/dist/defaults.js
ADDED
package/dist/emoji.d.ts
ADDED
|
@@ -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;
|