feat: add markdown-to-pdf converter with Puppeteer
Introduce a Node.js CLI tool that converts Markdown files to styled
PDFs using `marked` for parsing and `puppeteer` for rendering.
- Add `md-to-pdf.js` as the main conversion script with:
- CLI argument handling for input/output file paths
- Automatic output directory creation alongside the input file
- Pre-processing to strip thematic breaks (`---`) before headings
- Embedded CSS for clean, print-ready styling (headings, tables,
code blocks, blockquotes, page breaks, etc.)
- PDF generation via Puppeteer with A4 format and custom margins
- Add `.gitignore` to exclude `node_modules/` and `output/` directories
- Add `package-lock.json` with resolved dependencies (marked, puppeteer)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
output/
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { marked } = require('marked');
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
const inputFile = process.argv[2];
|
||||
|
||||
if (!inputFile) {
|
||||
console.error('Usage: node md-to-pdf.js <input.md> [output/custom.pdf]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`File not found: ${inputFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(path.dirname(inputFile), 'output');
|
||||
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile)) + '.pdf';
|
||||
const outputFile = process.argv[3]
|
||||
? path.resolve(process.argv[3])
|
||||
: path.join(outputDir, baseName);
|
||||
|
||||
const rawMarkdown = fs.readFileSync(inputFile, 'utf8');
|
||||
// Remove a thematic break (---) when it is immediately followed by a heading
|
||||
// (with only blank lines in between), so it doesn't render as <hr>.
|
||||
const markdown = rawMarkdown.replace(/^---[ \t]*$(\n[ \t]*)*(?=^#{1,6}\s)/gm, '');
|
||||
const body = marked(markdown);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 50px;
|
||||
}
|
||||
|
||||
/* ---------- Headings ---------- */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
break-after: avoid;
|
||||
}
|
||||
.page > h1:first-child,
|
||||
.page > h2:first-child,
|
||||
.page > h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
h1 { font-size: 2em; border-bottom: 2px solid #e0e0e0; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #e0e0e0; padding-bottom: 0.2em; }
|
||||
h3 { font-size: 1.2em; }
|
||||
h4 { font-size: 1em; }
|
||||
|
||||
/* ---------- Paragraphs & text ---------- */
|
||||
p { margin: 0.6em 0; }
|
||||
|
||||
a { color: #0969da; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
strong { font-weight: 700; }
|
||||
em { font-style: italic; }
|
||||
|
||||
/* ---------- Code ---------- */
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 1em 1.2em;
|
||||
overflow-x: auto;
|
||||
break-inside: avoid;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- Blockquote ---------- */
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.6em 1.2em;
|
||||
border-left: 4px solid #0969da;
|
||||
background: #f0f6ff;
|
||||
border-radius: 0 6px 6px 0;
|
||||
break-inside: avoid;
|
||||
color: #444;
|
||||
}
|
||||
blockquote p { margin: 0; }
|
||||
|
||||
/* ---------- Lists ---------- */
|
||||
ul, ol {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.8em;
|
||||
}
|
||||
li { margin: 0.25em 0; }
|
||||
|
||||
/* ---------- Horizontal rule ---------- */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 0.92em;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f0f1f3;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.55em 0.9em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* ---------- Images ---------- */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ---------- Page-break hints ---------- */
|
||||
p, li, blockquote, pre, img {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Avoid orphan headings at the bottom of a page */
|
||||
h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + * {
|
||||
break-before: avoid;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${body}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Viewport très haut pour que getBoundingClientRect() retourne des coords absolues
|
||||
await page.setViewport({ width: 794, height: 100000 });
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
// Si un h2 tombe dans la moitié basse d'une page A4, on le pousse à la page suivante.
|
||||
// On simule la pagination avec un décalage cumulatif : chaque saut de page forcé
|
||||
// avance les positions de tous les titres suivants, évitant l'effet cascade.
|
||||
await page.evaluate(() => {
|
||||
const MM_TO_PX = 96 / 25.4;
|
||||
const PAGE_H = Math.round(297 * MM_TO_PX); // 1123px — hauteur brute A4
|
||||
const MARGIN_TOP = Math.round(15 * MM_TO_PX); // 57px
|
||||
const MARGIN_BOT = Math.round(15 * MM_TO_PX); // 57px
|
||||
const CONTENT_H = PAGE_H - MARGIN_TOP - MARGIN_BOT; // ~1009px par page
|
||||
|
||||
let cumulativeShift = 0;
|
||||
|
||||
document.querySelectorAll('h2').forEach(h => {
|
||||
// Position originale (layout écran, sans sauts de page CSS)
|
||||
const originalY = h.getBoundingClientRect().top;
|
||||
// Position effective après les sauts de page déjà décidés avant ce titre
|
||||
const effectiveY = originalY + cumulativeShift;
|
||||
const pageNum = Math.floor(effectiveY / CONTENT_H);
|
||||
const posInPage = effectiveY % CONTENT_H;
|
||||
|
||||
if (posInPage > CONTENT_H / 2) {
|
||||
h.style.pageBreakBefore = 'always';
|
||||
// Ce titre est repoussé au début de la page suivante :
|
||||
// on calcule le décalage ajouté et on l'accumule pour les titres suivants
|
||||
const newEffectiveY = (pageNum + 1) * CONTENT_H;
|
||||
cumulativeShift += newEffectiveY - effectiveY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.pdf({
|
||||
path: outputFile,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '15mm', bottom: '15mm', left: '10mm', right: '10mm' },
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`PDF generated: ${outputFile}`);
|
||||
})();
|
||||
Generated
+1150
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "markdown-to-pdf",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://gitea@192.168.1.62/hykocx/markdown-to-pdf.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"marked": "^18.0.0",
|
||||
"puppeteer": "^24.40.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user