@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env ts-node
2
+ export {};
@@ -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,2 @@
1
+ import type { PdfOptions } from './types.js';
2
+ export declare function renderMarkdownToPdf(markdown: string, options?: PdfOptions): Promise<Buffer>;
@@ -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,3 @@
1
+ import type { ThemeConfig, PageLayout } from './types.js';
2
+ export declare const defaultTheme: ThemeConfig;
3
+ export declare const defaultPageLayout: PageLayout;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ }