0f09b30164
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)
244 lines
6.4 KiB
JavaScript
244 lines
6.4 KiB
JavaScript
#!/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}`);
|
|
})();
|