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:
2026-04-15 11:28:16 -04:00
parent ffeaea1f38
commit 0f09b30164
4 changed files with 1416 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
output/
+243
View File
@@ -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}`);
})();
+1150
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}