@speajus/markdown-to-pdf 1.0.0
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 +167 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +26 -0
- package/dist/src/renderer.d.ts +2 -0
- package/dist/src/renderer.js +423 -0
- package/dist/src/styles.d.ts +3 -0
- package/dist/src/styles.js +56 -0
- package/dist/src/types.d.ts +62 -0
- package/dist/src/types.js +2 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Basic-Markdown-To-PDF
|
|
2
|
+
|
|
3
|
+
A lightweight TypeScript library that converts Markdown files into styled PDF documents. Built on [marked](https://github.com/markedjs/marked) for parsing and [PDFKit](https://pdfkit.org/) for PDF generation.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Headings** (h1–h6) with configurable fonts, sizes, and colors
|
|
8
|
+
- **Inline formatting** — bold, italic, bold-italic, strikethrough, inline code
|
|
9
|
+
- **Code blocks** with monospace font and background shading
|
|
10
|
+
- **Blockquotes** with colored left border
|
|
11
|
+
- **Lists** — ordered and unordered, with nested sub-lists
|
|
12
|
+
- **Tables** with header row highlighting and cell borders
|
|
13
|
+
- **Links** rendered as clickable PDF hyperlinks
|
|
14
|
+
- **Images** — local (PNG, JPEG) and remote (HTTP/HTTPS) with automatic SVG-to-PNG conversion via [@resvg/resvg-js](https://github.com/nicolo-ribaudo/resvg-js)
|
|
15
|
+
- **Horizontal rules**
|
|
16
|
+
- **Automatic page breaks** when content exceeds the current page
|
|
17
|
+
- **Fully themeable** — customize fonts, colors, spacing, page size, and margins
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
### CLI
|
|
28
|
+
|
|
29
|
+
Convert a Markdown file to PDF from the command line:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx ts-node src/cli.ts <input.md> [output.pdf]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If the output path is omitted, the PDF is written alongside the input file with a `.pdf` extension.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Converts README.md → README.pdf
|
|
39
|
+
npx ts-node src/cli.ts README.md
|
|
40
|
+
|
|
41
|
+
# Explicit output path
|
|
42
|
+
npx ts-node src/cli.ts docs/report.md output/report.pdf
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Programmatic API
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { generatePdf } from './src/index';
|
|
49
|
+
|
|
50
|
+
// File-based — reads Markdown from disk, writes PDF to disk
|
|
51
|
+
await generatePdf('samples/sample.md', 'output/sample.pdf');
|
|
52
|
+
|
|
53
|
+
// Buffer-based — returns a PDF Buffer for further processing
|
|
54
|
+
import { renderMarkdownToPdf } from './src/index';
|
|
55
|
+
|
|
56
|
+
const markdown = '# Hello World\n\nThis is a **test**.';
|
|
57
|
+
const pdfBuffer = await renderMarkdownToPdf(markdown);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Generate Sample PDFs
|
|
61
|
+
|
|
62
|
+
The `samples/` directory contains example Markdown files. Generate PDFs for all of them at once:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run generate
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Output is written to the `output/` directory.
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
Both `generatePdf` and `renderMarkdownToPdf` accept an optional `PdfOptions` object:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
interface PdfOptions {
|
|
76
|
+
theme?: ThemeConfig; // Typography, colors, and component styles
|
|
77
|
+
pageLayout?: PageLayout; // Page size and margins
|
|
78
|
+
basePath?: string; // Base directory for resolving relative image paths
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Page Layout
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { generatePdf } from './src/index';
|
|
86
|
+
|
|
87
|
+
await generatePdf('input.md', 'output.pdf', {
|
|
88
|
+
pageLayout: {
|
|
89
|
+
pageSize: 'A4',
|
|
90
|
+
margins: { top: 72, right: 72, bottom: 72, left: 72 },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The default layout uses **Letter** page size with 50pt margins on all sides.
|
|
96
|
+
|
|
97
|
+
### Theming
|
|
98
|
+
|
|
99
|
+
Override any part of the default theme to customize the look of the generated PDF:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { generatePdf, defaultTheme } from './src/index';
|
|
103
|
+
|
|
104
|
+
await generatePdf('input.md', 'output.pdf', {
|
|
105
|
+
theme: {
|
|
106
|
+
...defaultTheme,
|
|
107
|
+
headings: {
|
|
108
|
+
...defaultTheme.headings,
|
|
109
|
+
h1: { font: 'Helvetica-Bold', fontSize: 32, color: '#0a3d62', bold: true },
|
|
110
|
+
},
|
|
111
|
+
linkColor: '#e74c3c',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The full `ThemeConfig` interface exposes styles for:
|
|
117
|
+
|
|
118
|
+
| Section | Configurable properties |
|
|
119
|
+
| -------------- | --------------------------------------------------- |
|
|
120
|
+
| `headings` | Font, size, and color for each level (h1–h6) |
|
|
121
|
+
| `body` | Font, size, color, and line gap |
|
|
122
|
+
| `code.inline` | Font, size, color, and background color |
|
|
123
|
+
| `code.block` | Font, size, color, background color, and padding |
|
|
124
|
+
| `blockquote` | Border color, border width, italic flag, and indent |
|
|
125
|
+
| `table` | Header background, border color, and cell padding |
|
|
126
|
+
| `linkColor` | Color for hyperlink text |
|
|
127
|
+
| `horizontalRuleColor` | Color for `---` dividers |
|
|
128
|
+
|
|
129
|
+
## Project Structure
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
├── src/
|
|
133
|
+
│ ├── index.ts # Public API — generatePdf, renderMarkdownToPdf, exports
|
|
134
|
+
│ ├── cli.ts # Command-line entry point
|
|
135
|
+
│ ├── renderer.ts # Markdown-to-PDF rendering engine
|
|
136
|
+
│ ├── styles.ts # Default theme and page layout
|
|
137
|
+
│ └── types.ts # TypeScript interfaces for options and theming
|
|
138
|
+
├── samples/
|
|
139
|
+
│ ├── generate.ts # Script to batch-generate sample PDFs
|
|
140
|
+
│ ├── sample.md # Full-featured sample document
|
|
141
|
+
│ ├── image.md # Image rendering tests (local, remote, SVG, broken)
|
|
142
|
+
│ ├── logo.svg # Sample SVG image
|
|
143
|
+
│ ├── logo.png # Sample PNG image
|
|
144
|
+
│ └── sample.png # Sample raster image
|
|
145
|
+
├── output/ # Generated PDF output directory
|
|
146
|
+
├── package.json
|
|
147
|
+
└── tsconfig.json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Dependencies
|
|
151
|
+
|
|
152
|
+
| Package | Purpose |
|
|
153
|
+
| ------- | ------- |
|
|
154
|
+
| [marked](https://github.com/markedjs/marked) | Markdown parsing and tokenization |
|
|
155
|
+
| [pdfkit](https://pdfkit.org/) | PDF document generation |
|
|
156
|
+
| [@resvg/resvg-js](https://github.com/nicolo-ribaudo/resvg-js) | SVG-to-PNG rasterization for image embedding |
|
|
157
|
+
|
|
158
|
+
## Scripts
|
|
159
|
+
|
|
160
|
+
| Command | Description |
|
|
161
|
+
| ------- | ----------- |
|
|
162
|
+
| `npm run build` | Compile TypeScript to `dist/` |
|
|
163
|
+
| `npm run generate` | Generate sample PDFs from `samples/*.md` into `output/` |
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
ISC
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env ts-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
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const index_js_1 = require("./index.js");
|
|
9
|
+
async function main() {
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
if (args.length === 0) {
|
|
12
|
+
console.error('Usage: ts-node src/cli.ts <input.md> [output.pdf]');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const inputPath = args[0];
|
|
16
|
+
const outputPath = args[1] ?? inputPath.replace(/\.md$/i, '.pdf');
|
|
17
|
+
console.log(`Converting ${inputPath} → ${outputPath}`);
|
|
18
|
+
await (0, index_js_1.generatePdf)(inputPath, outputPath);
|
|
19
|
+
console.log(`Done. PDF written to ${path_1.default.resolve(outputPath)}`);
|
|
20
|
+
}
|
|
21
|
+
main().catch((err) => {
|
|
22
|
+
console.error('Error:', err);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { TextStyle, PageLayout, CodeStyle, CodeBlockStyle, BlockquoteStyle, TableStyles, ThemeConfig, PdfOptions, } from './types.js';
|
|
2
|
+
export { renderMarkdownToPdf } from './renderer.js';
|
|
3
|
+
export { defaultTheme, defaultPageLayout } from './styles.js';
|
|
4
|
+
import type { PdfOptions } from './types.js';
|
|
5
|
+
export declare function generatePdf(inputPath: string, outputPath: string, options?: PdfOptions): Promise<void>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.defaultPageLayout = exports.defaultTheme = exports.renderMarkdownToPdf = void 0;
|
|
7
|
+
exports.generatePdf = generatePdf;
|
|
8
|
+
var renderer_js_1 = require("./renderer.js");
|
|
9
|
+
Object.defineProperty(exports, "renderMarkdownToPdf", { enumerable: true, get: function () { return renderer_js_1.renderMarkdownToPdf; } });
|
|
10
|
+
var styles_js_1 = require("./styles.js");
|
|
11
|
+
Object.defineProperty(exports, "defaultTheme", { enumerable: true, get: function () { return styles_js_1.defaultTheme; } });
|
|
12
|
+
Object.defineProperty(exports, "defaultPageLayout", { enumerable: true, get: function () { return styles_js_1.defaultPageLayout; } });
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const renderer_js_2 = require("./renderer.js");
|
|
16
|
+
async function generatePdf(inputPath, outputPath, options) {
|
|
17
|
+
const resolvedInput = path_1.default.resolve(inputPath);
|
|
18
|
+
const markdown = fs_1.default.readFileSync(resolvedInput, 'utf-8');
|
|
19
|
+
const basePath = path_1.default.dirname(resolvedInput);
|
|
20
|
+
const mergedOptions = { ...options, basePath: options?.basePath ?? basePath };
|
|
21
|
+
const buffer = await (0, renderer_js_2.renderMarkdownToPdf)(markdown, mergedOptions);
|
|
22
|
+
const dir = path_1.default.dirname(path_1.default.resolve(outputPath));
|
|
23
|
+
if (!fs_1.default.existsSync(dir))
|
|
24
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
25
|
+
fs_1.default.writeFileSync(path_1.default.resolve(outputPath), buffer);
|
|
26
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderMarkdownToPdf = renderMarkdownToPdf;
|
|
7
|
+
const styles_js_1 = require("./styles.js");
|
|
8
|
+
const pdfkit_1 = __importDefault(require("pdfkit"));
|
|
9
|
+
const marked_1 = require("marked");
|
|
10
|
+
const resvg_js_1 = require("@resvg/resvg-js");
|
|
11
|
+
const stream_1 = require("stream");
|
|
12
|
+
const https_1 = __importDefault(require("https"));
|
|
13
|
+
const http_1 = __importDefault(require("http"));
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
function isSvg(buf) {
|
|
17
|
+
// Check for XML/SVG signature in the first 256 bytes
|
|
18
|
+
const head = buf.subarray(0, 256).toString('utf-8').trimStart();
|
|
19
|
+
return head.startsWith('<svg') || head.startsWith('<?xml');
|
|
20
|
+
}
|
|
21
|
+
function convertSvgToPng(svgData) {
|
|
22
|
+
const resvg = new resvg_js_1.Resvg(svgData, { font: { loadSystemFonts: true } });
|
|
23
|
+
const rendered = resvg.render();
|
|
24
|
+
return Buffer.from(rendered.asPng());
|
|
25
|
+
}
|
|
26
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
27
|
+
const MAX_REDIRECTS = 5;
|
|
28
|
+
function fetchImageBuffer(url, redirectCount = 0) {
|
|
29
|
+
if (redirectCount > MAX_REDIRECTS) {
|
|
30
|
+
return Promise.reject(new Error(`Too many redirects fetching ${url}`));
|
|
31
|
+
}
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const get = url.startsWith('https') ? https_1.default.get : http_1.default.get;
|
|
34
|
+
const req = get(url, (res) => {
|
|
35
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
36
|
+
res.resume(); // drain the response so the socket can be reused / freed
|
|
37
|
+
fetchImageBuffer(res.headers.location, redirectCount + 1).then(resolve, reject);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
41
|
+
res.resume();
|
|
42
|
+
reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const chunks = [];
|
|
46
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
47
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
48
|
+
res.on('error', reject);
|
|
49
|
+
});
|
|
50
|
+
req.on('error', reject);
|
|
51
|
+
req.setTimeout(FETCH_TIMEOUT_MS, () => {
|
|
52
|
+
req.destroy(new Error(`Timeout fetching ${url} after ${FETCH_TIMEOUT_MS}ms`));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function renderMarkdownToPdf(markdown, options) {
|
|
57
|
+
const theme = options?.theme ?? styles_js_1.defaultTheme;
|
|
58
|
+
const layout = options?.pageLayout ?? styles_js_1.defaultPageLayout;
|
|
59
|
+
const basePath = options?.basePath ?? process.cwd();
|
|
60
|
+
const { margins } = layout;
|
|
61
|
+
const doc = new pdfkit_1.default({ size: layout.pageSize, margins });
|
|
62
|
+
const stream = new stream_1.PassThrough();
|
|
63
|
+
const chunks = [];
|
|
64
|
+
doc.pipe(stream);
|
|
65
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
66
|
+
const tokens = marked_1.marked.lexer(markdown);
|
|
67
|
+
const contentWidth = doc.page.width - margins.left - margins.right;
|
|
68
|
+
function ensureSpace(needed) {
|
|
69
|
+
if (doc.y + needed > doc.page.height - margins.bottom) {
|
|
70
|
+
doc.addPage();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function applyBodyFont(bold, italic) {
|
|
74
|
+
let font = theme.body.font;
|
|
75
|
+
if (bold && italic)
|
|
76
|
+
font = 'Helvetica-BoldOblique';
|
|
77
|
+
else if (bold)
|
|
78
|
+
font = 'Helvetica-Bold';
|
|
79
|
+
else if (italic)
|
|
80
|
+
font = 'Helvetica-Oblique';
|
|
81
|
+
doc.font(font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
82
|
+
}
|
|
83
|
+
function resetBodyFont() {
|
|
84
|
+
doc.font(theme.body.font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
85
|
+
}
|
|
86
|
+
function renderCodespan(text, continued) {
|
|
87
|
+
const cs = theme.code.inline;
|
|
88
|
+
doc.font(cs.font).fontSize(cs.fontSize);
|
|
89
|
+
const textW = doc.widthOfString(text);
|
|
90
|
+
const textH = doc.currentLineHeight();
|
|
91
|
+
const bgX = doc.x;
|
|
92
|
+
const bgY = doc.y;
|
|
93
|
+
doc.save();
|
|
94
|
+
doc.rect(bgX, bgY, textW + 4, textH + 2).fill(cs.backgroundColor);
|
|
95
|
+
doc.restore();
|
|
96
|
+
doc.font(cs.font).fontSize(cs.fontSize).fillColor(cs.color);
|
|
97
|
+
doc.text(text, bgX + 2, bgY + 1, { continued });
|
|
98
|
+
resetBodyFont();
|
|
99
|
+
}
|
|
100
|
+
function renderLink(tok, continued) {
|
|
101
|
+
doc.font(theme.body.font).fontSize(theme.body.fontSize).fillColor(theme.linkColor);
|
|
102
|
+
const linkText = tok.text || tok.href;
|
|
103
|
+
doc.text(linkText, { continued, underline: true, link: tok.href });
|
|
104
|
+
doc.fillColor(theme.body.color);
|
|
105
|
+
}
|
|
106
|
+
async function renderInlineTokens(inlineTokens, continued, insideBold = false, insideItalic = false) {
|
|
107
|
+
for (let i = 0; i < inlineTokens.length; i++) {
|
|
108
|
+
const isLast = i === inlineTokens.length - 1;
|
|
109
|
+
const cont = continued || !isLast;
|
|
110
|
+
const tok = inlineTokens[i];
|
|
111
|
+
switch (tok.type) {
|
|
112
|
+
case 'text': {
|
|
113
|
+
const t = tok;
|
|
114
|
+
if (t.tokens && t.tokens.length > 0) {
|
|
115
|
+
await renderInlineTokens(t.tokens, cont, insideBold, insideItalic);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
applyBodyFont(insideBold, insideItalic);
|
|
119
|
+
doc.text(t.text, { continued: cont });
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case 'strong': {
|
|
124
|
+
const t = tok;
|
|
125
|
+
await renderInlineTokens(t.tokens, cont, true, insideItalic);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'em': {
|
|
129
|
+
const t = tok;
|
|
130
|
+
await renderInlineTokens(t.tokens, cont, insideBold, true);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'codespan': {
|
|
134
|
+
renderCodespan(tok.text, cont);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'link': {
|
|
138
|
+
renderLink(tok, cont);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'image': {
|
|
142
|
+
await renderImage(tok);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'del': {
|
|
146
|
+
applyBodyFont(insideBold, insideItalic);
|
|
147
|
+
doc.text(tok.text, { continued: cont, strike: true });
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'escape': {
|
|
151
|
+
applyBodyFont(insideBold, insideItalic);
|
|
152
|
+
doc.text(tok.text, { continued: cont });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'br': {
|
|
156
|
+
doc.moveDown(0.5);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
default: {
|
|
160
|
+
const raw = tok.text ?? tok.raw ?? '';
|
|
161
|
+
if (raw) {
|
|
162
|
+
applyBodyFont(insideBold, insideItalic);
|
|
163
|
+
doc.text(raw, { continued: cont });
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function renderImage(tok) {
|
|
171
|
+
try {
|
|
172
|
+
let imgBuffer;
|
|
173
|
+
if (tok.href.startsWith('http://') || tok.href.startsWith('https://')) {
|
|
174
|
+
imgBuffer = await fetchImageBuffer(tok.href);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Local file path — resolve relative to the markdown file's directory
|
|
178
|
+
const imgPath = path_1.default.resolve(basePath, tok.href);
|
|
179
|
+
imgBuffer = fs_1.default.readFileSync(imgPath);
|
|
180
|
+
}
|
|
181
|
+
// Convert SVG to PNG since pdfkit doesn't support SVG natively
|
|
182
|
+
if (isSvg(imgBuffer)) {
|
|
183
|
+
imgBuffer = convertSvgToPng(imgBuffer);
|
|
184
|
+
}
|
|
185
|
+
// Read the image's intrinsic dimensions via pdfkit
|
|
186
|
+
// openImage exists at runtime but is missing from @types/pdfkit
|
|
187
|
+
const img = doc.openImage(imgBuffer);
|
|
188
|
+
const maxHeight = doc.page.height - margins.top - margins.bottom;
|
|
189
|
+
// Scale down to fit content area, but never scale up beyond natural size
|
|
190
|
+
let displayWidth = Math.min(img.width, contentWidth);
|
|
191
|
+
let displayHeight = img.height * (displayWidth / img.width);
|
|
192
|
+
// Also cap height to the printable area
|
|
193
|
+
if (displayHeight > maxHeight) {
|
|
194
|
+
displayHeight = maxHeight;
|
|
195
|
+
displayWidth = img.width * (displayHeight / img.height);
|
|
196
|
+
}
|
|
197
|
+
ensureSpace(displayHeight + 10);
|
|
198
|
+
doc.image(imgBuffer, { width: displayWidth, height: displayHeight });
|
|
199
|
+
doc.moveDown(0.5);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
ensureSpace(20);
|
|
203
|
+
resetBodyFont();
|
|
204
|
+
doc.text(`[Image: ${tok.text || 'image'}]`);
|
|
205
|
+
doc.moveDown(0.3);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function renderList(list, depth) {
|
|
209
|
+
const indent = margins.left + depth * 20;
|
|
210
|
+
for (let idx = 0; idx < list.items.length; idx++) {
|
|
211
|
+
const item = list.items[idx];
|
|
212
|
+
ensureSpace(theme.body.fontSize * 2);
|
|
213
|
+
resetBodyFont();
|
|
214
|
+
const bullet = list.ordered ? `${list.start + idx}.` : '•';
|
|
215
|
+
doc.text(bullet, indent, doc.y, { continued: true, width: contentWidth - depth * 20 });
|
|
216
|
+
doc.text(' ', { continued: true });
|
|
217
|
+
// Render item inline tokens
|
|
218
|
+
const itemTokens = item.tokens;
|
|
219
|
+
for (const child of itemTokens) {
|
|
220
|
+
if (child.type === 'text') {
|
|
221
|
+
const t = child;
|
|
222
|
+
if (t.tokens && t.tokens.length > 0) {
|
|
223
|
+
await renderInlineTokens(t.tokens, false);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
doc.text(t.text);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (child.type === 'paragraph') {
|
|
230
|
+
await renderInlineTokens(child.tokens, false);
|
|
231
|
+
}
|
|
232
|
+
else if (child.type === 'list') {
|
|
233
|
+
await renderList(child, depth + 1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
doc.moveDown(0.2);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function renderTable(table) {
|
|
240
|
+
const colCount = table.header.length;
|
|
241
|
+
if (colCount === 0)
|
|
242
|
+
return;
|
|
243
|
+
const cellPad = theme.table.cellPadding;
|
|
244
|
+
const colWidth = contentWidth / colCount;
|
|
245
|
+
const rowH = theme.body.fontSize + cellPad * 2 + 4;
|
|
246
|
+
const textInsetY = (rowH - theme.body.fontSize) / 2;
|
|
247
|
+
ensureSpace(rowH * 2);
|
|
248
|
+
const startX = margins.left;
|
|
249
|
+
let y = doc.y;
|
|
250
|
+
// Header row
|
|
251
|
+
doc.save();
|
|
252
|
+
doc.rect(startX, y, contentWidth, rowH).fill(theme.table.headerBackground);
|
|
253
|
+
doc.restore();
|
|
254
|
+
doc.font('Helvetica-Bold').fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
255
|
+
for (let c = 0; c < colCount; c++) {
|
|
256
|
+
const cellX = startX + c * colWidth;
|
|
257
|
+
doc.text(table.header[c].text, cellX + cellPad, y + textInsetY, {
|
|
258
|
+
width: colWidth - cellPad * 2,
|
|
259
|
+
height: rowH,
|
|
260
|
+
align: table.align[c] || 'left',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// Header border
|
|
264
|
+
doc.save();
|
|
265
|
+
doc.strokeColor(theme.table.borderColor).lineWidth(0.5);
|
|
266
|
+
doc.rect(startX, y, contentWidth, rowH).stroke();
|
|
267
|
+
for (let c = 1; c < colCount; c++) {
|
|
268
|
+
const cx = startX + c * colWidth;
|
|
269
|
+
doc.moveTo(cx, y).lineTo(cx, y + rowH).stroke();
|
|
270
|
+
}
|
|
271
|
+
doc.restore();
|
|
272
|
+
y += rowH;
|
|
273
|
+
// Body rows
|
|
274
|
+
resetBodyFont();
|
|
275
|
+
for (const row of table.rows) {
|
|
276
|
+
ensureSpace(rowH);
|
|
277
|
+
for (let c = 0; c < colCount; c++) {
|
|
278
|
+
const cellX = startX + c * colWidth;
|
|
279
|
+
doc.text(row[c].text, cellX + cellPad, y + textInsetY, {
|
|
280
|
+
width: colWidth - cellPad * 2,
|
|
281
|
+
height: rowH,
|
|
282
|
+
align: table.align[c] || 'left',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
doc.save();
|
|
286
|
+
doc.strokeColor(theme.table.borderColor).lineWidth(0.5);
|
|
287
|
+
doc.rect(startX, y, contentWidth, rowH).stroke();
|
|
288
|
+
for (let c = 1; c < colCount; c++) {
|
|
289
|
+
const cx = startX + c * colWidth;
|
|
290
|
+
doc.moveTo(cx, y).lineTo(cx, y + rowH).stroke();
|
|
291
|
+
}
|
|
292
|
+
doc.restore();
|
|
293
|
+
y += rowH;
|
|
294
|
+
}
|
|
295
|
+
doc.x = margins.left;
|
|
296
|
+
doc.y = y;
|
|
297
|
+
doc.moveDown(0.5);
|
|
298
|
+
resetBodyFont();
|
|
299
|
+
}
|
|
300
|
+
async function renderToken(token) {
|
|
301
|
+
switch (token.type) {
|
|
302
|
+
case 'heading': {
|
|
303
|
+
const t = token;
|
|
304
|
+
const key = `h${t.depth}`;
|
|
305
|
+
const style = theme.headings[key];
|
|
306
|
+
const spaceAbove = style.fontSize * 0.8;
|
|
307
|
+
const spaceBelow = style.fontSize * 0.3;
|
|
308
|
+
ensureSpace(spaceAbove + style.fontSize + spaceBelow);
|
|
309
|
+
doc.moveDown(spaceAbove / doc.currentLineHeight());
|
|
310
|
+
doc.font(style.font).fontSize(style.fontSize).fillColor(style.color);
|
|
311
|
+
doc.text(t.text);
|
|
312
|
+
doc.moveDown(spaceBelow / doc.currentLineHeight());
|
|
313
|
+
resetBodyFont();
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case 'paragraph': {
|
|
317
|
+
const t = token;
|
|
318
|
+
ensureSpace(theme.body.fontSize * 2);
|
|
319
|
+
resetBodyFont();
|
|
320
|
+
await renderInlineTokens(t.tokens, false);
|
|
321
|
+
doc.moveDown(0.5);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case 'code': {
|
|
325
|
+
const t = token;
|
|
326
|
+
const cs = theme.code.block;
|
|
327
|
+
const lines = t.text.split('\n');
|
|
328
|
+
const lineH = cs.fontSize * 1.4;
|
|
329
|
+
const blockH = lines.length * lineH + cs.padding * 2;
|
|
330
|
+
ensureSpace(blockH + 10);
|
|
331
|
+
const x = margins.left;
|
|
332
|
+
const y = doc.y;
|
|
333
|
+
doc.save();
|
|
334
|
+
doc.rect(x, y, contentWidth, blockH).fill(cs.backgroundColor);
|
|
335
|
+
doc.restore();
|
|
336
|
+
doc.font(cs.font).fontSize(cs.fontSize).fillColor(cs.color);
|
|
337
|
+
let textY = y + cs.padding;
|
|
338
|
+
for (const line of lines) {
|
|
339
|
+
doc.text(line, x + cs.padding, textY, { width: contentWidth - cs.padding * 2 });
|
|
340
|
+
textY += lineH;
|
|
341
|
+
}
|
|
342
|
+
doc.x = margins.left;
|
|
343
|
+
doc.y = y + blockH;
|
|
344
|
+
doc.moveDown(0.5);
|
|
345
|
+
resetBodyFont();
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
case 'blockquote': {
|
|
349
|
+
const t = token;
|
|
350
|
+
const bq = theme.blockquote;
|
|
351
|
+
ensureSpace(30);
|
|
352
|
+
const bqPadding = 6; // vertical padding above and below text
|
|
353
|
+
const startY = doc.y;
|
|
354
|
+
doc.y += bqPadding; // add top padding before text
|
|
355
|
+
const textX = margins.left + bq.borderWidth + bq.indent;
|
|
356
|
+
const textWidth = contentWidth - bq.borderWidth - bq.indent;
|
|
357
|
+
for (const child of t.tokens) {
|
|
358
|
+
if (child.type === 'paragraph') {
|
|
359
|
+
const p = child;
|
|
360
|
+
const font = bq.italic ? 'Helvetica-Oblique' : theme.body.font;
|
|
361
|
+
doc.font(font).fontSize(theme.body.fontSize).fillColor(theme.body.color);
|
|
362
|
+
doc.text('', textX, doc.y, { width: textWidth });
|
|
363
|
+
await renderInlineTokens(p.tokens, false, false, bq.italic);
|
|
364
|
+
doc.moveDown(0.3);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
await renderToken(child);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
doc.y += bqPadding; // add bottom padding after text
|
|
371
|
+
const endY = doc.y;
|
|
372
|
+
doc.save();
|
|
373
|
+
doc.rect(margins.left, startY, bq.borderWidth, endY - startY).fill(bq.borderColor);
|
|
374
|
+
doc.restore();
|
|
375
|
+
doc.x = margins.left;
|
|
376
|
+
doc.moveDown(0.3);
|
|
377
|
+
resetBodyFont();
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'list': {
|
|
381
|
+
await renderList(token, 0);
|
|
382
|
+
doc.moveDown(0.3);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case 'hr': {
|
|
386
|
+
ensureSpace(20);
|
|
387
|
+
doc.moveDown(0.5);
|
|
388
|
+
const y = doc.y;
|
|
389
|
+
doc.save();
|
|
390
|
+
doc.strokeColor(theme.horizontalRuleColor).lineWidth(1)
|
|
391
|
+
.moveTo(margins.left, y)
|
|
392
|
+
.lineTo(margins.left + contentWidth, y)
|
|
393
|
+
.stroke();
|
|
394
|
+
doc.restore();
|
|
395
|
+
doc.y = y;
|
|
396
|
+
doc.moveDown(0.5);
|
|
397
|
+
resetBodyFont();
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'table': {
|
|
401
|
+
await renderTable(token);
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 'image': {
|
|
405
|
+
await renderImage(token);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'space':
|
|
409
|
+
case 'html':
|
|
410
|
+
break;
|
|
411
|
+
default:
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// ── Main loop ─────────────────────────────────────────────────────────────
|
|
416
|
+
for (const token of tokens) {
|
|
417
|
+
await renderToken(token);
|
|
418
|
+
}
|
|
419
|
+
doc.end();
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
422
|
+
});
|
|
423
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defaultPageLayout = exports.defaultTheme = void 0;
|
|
4
|
+
exports.defaultTheme = {
|
|
5
|
+
headings: {
|
|
6
|
+
h1: { font: 'Helvetica-Bold', fontSize: 28, color: '#1a1a1a', bold: true },
|
|
7
|
+
h2: { font: 'Helvetica-Bold', fontSize: 22, color: '#2a2a2a', bold: true },
|
|
8
|
+
h3: { font: 'Helvetica-Bold', fontSize: 18, color: '#3a3a3a', bold: true },
|
|
9
|
+
h4: { font: 'Helvetica-Bold', fontSize: 16, color: '#4a4a4a', bold: true },
|
|
10
|
+
h5: { font: 'Helvetica-Bold', fontSize: 14, color: '#5a5a5a', bold: true },
|
|
11
|
+
h6: { font: 'Helvetica-Bold', fontSize: 12, color: '#6a6a6a', bold: true },
|
|
12
|
+
},
|
|
13
|
+
body: {
|
|
14
|
+
font: 'Helvetica',
|
|
15
|
+
fontSize: 11,
|
|
16
|
+
color: '#333333',
|
|
17
|
+
lineGap: 4,
|
|
18
|
+
},
|
|
19
|
+
code: {
|
|
20
|
+
inline: {
|
|
21
|
+
font: 'Courier',
|
|
22
|
+
fontSize: 10,
|
|
23
|
+
color: '#c7254e',
|
|
24
|
+
backgroundColor: '#f9f2f4',
|
|
25
|
+
},
|
|
26
|
+
block: {
|
|
27
|
+
font: 'Courier',
|
|
28
|
+
fontSize: 9,
|
|
29
|
+
color: '#333333',
|
|
30
|
+
backgroundColor: '#f5f5f5',
|
|
31
|
+
padding: 8,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
blockquote: {
|
|
35
|
+
borderColor: '#3498db',
|
|
36
|
+
borderWidth: 3,
|
|
37
|
+
italic: true,
|
|
38
|
+
indent: 20,
|
|
39
|
+
},
|
|
40
|
+
linkColor: '#2980b9',
|
|
41
|
+
horizontalRuleColor: '#cccccc',
|
|
42
|
+
table: {
|
|
43
|
+
headerBackground: '#f0f0f0',
|
|
44
|
+
borderColor: '#cccccc',
|
|
45
|
+
cellPadding: 6,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
exports.defaultPageLayout = {
|
|
49
|
+
pageSize: 'LETTER',
|
|
50
|
+
margins: {
|
|
51
|
+
top: 50,
|
|
52
|
+
right: 50,
|
|
53
|
+
bottom: 50,
|
|
54
|
+
left: 50,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface TextStyle {
|
|
2
|
+
font: string;
|
|
3
|
+
fontSize: number;
|
|
4
|
+
color: string;
|
|
5
|
+
lineGap?: number;
|
|
6
|
+
bold?: boolean;
|
|
7
|
+
italic?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface PageLayout {
|
|
10
|
+
pageSize: string;
|
|
11
|
+
margins: {
|
|
12
|
+
top: number;
|
|
13
|
+
right: number;
|
|
14
|
+
bottom: number;
|
|
15
|
+
left: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface CodeStyle {
|
|
19
|
+
font: string;
|
|
20
|
+
fontSize: number;
|
|
21
|
+
color: string;
|
|
22
|
+
backgroundColor: string;
|
|
23
|
+
}
|
|
24
|
+
export interface CodeBlockStyle extends CodeStyle {
|
|
25
|
+
padding: number;
|
|
26
|
+
}
|
|
27
|
+
export interface BlockquoteStyle {
|
|
28
|
+
borderColor: string;
|
|
29
|
+
borderWidth: number;
|
|
30
|
+
italic: boolean;
|
|
31
|
+
indent: number;
|
|
32
|
+
}
|
|
33
|
+
export interface TableStyles {
|
|
34
|
+
headerBackground: string;
|
|
35
|
+
borderColor: string;
|
|
36
|
+
cellPadding: number;
|
|
37
|
+
}
|
|
38
|
+
export interface ThemeConfig {
|
|
39
|
+
headings: {
|
|
40
|
+
h1: TextStyle;
|
|
41
|
+
h2: TextStyle;
|
|
42
|
+
h3: TextStyle;
|
|
43
|
+
h4: TextStyle;
|
|
44
|
+
h5: TextStyle;
|
|
45
|
+
h6: TextStyle;
|
|
46
|
+
};
|
|
47
|
+
body: TextStyle;
|
|
48
|
+
code: {
|
|
49
|
+
inline: CodeStyle;
|
|
50
|
+
block: CodeBlockStyle;
|
|
51
|
+
};
|
|
52
|
+
blockquote: BlockquoteStyle;
|
|
53
|
+
linkColor: string;
|
|
54
|
+
horizontalRuleColor: string;
|
|
55
|
+
table: TableStyles;
|
|
56
|
+
}
|
|
57
|
+
export interface PdfOptions {
|
|
58
|
+
theme?: ThemeConfig;
|
|
59
|
+
pageLayout?: PageLayout;
|
|
60
|
+
/** Base directory for resolving relative image paths */
|
|
61
|
+
basePath?: string;
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@speajus/markdown-to-pdf",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A new project created with Intent by Augment.",
|
|
5
|
+
"main": "dist/src/index.js",
|
|
6
|
+
"types": "dist/src/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/src"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"generate": "ts-node samples/generate.ts",
|
|
13
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["markdown", "pdf", "converter"],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/speajus/markdown-to-pdf"
|
|
21
|
+
},
|
|
22
|
+
"type": "commonjs",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
25
|
+
"marked": "^17.0.2",
|
|
26
|
+
"pdfkit": "^0.17.2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.2.3",
|
|
30
|
+
"@types/pdfkit": "^0.17.5",
|
|
31
|
+
"ts-node": "^10.9.2",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
}
|
|
34
|
+
}
|