Compare commits

..
1 Commits
Author SHA1 Message Date
hykocx 7cc0aa5d81 Initial commit 2026-04-15 11:40:53 -04:00
5 changed files with 1 additions and 1450 deletions
-2
View File
@@ -1,2 +0,0 @@
node_modules/
output/
+1 -34
View File
@@ -1,36 +1,3 @@
# markdown-to-pdf # markdown-to-pdf
Convertit un fichier Markdown en PDF bien mis en forme. Fond blanc, typographie soignée, sans coupures de page au milieu des tableaux ou des blocs de code. Convertit un fichier Markdown en PDF bien mis en forme.
## Prérequis
Node.js installé.
## Installation
```bash
npm install
```
## Utilisation
```bash
node md-to-pdf.js <fichier.md>
```
Le PDF est généré dans `output/<fichier>.pdf`. Le dossier `output/` est créé automatiquement s'il n'existe pas.
Pour choisir un chemin de sortie différent :
```bash
node md-to-pdf.js rapport.md /tmp/rapport.pdf
```
## Ce que le script gère
- Tableaux, blocs de code, citations et images jamais coupés entre deux pages
- Les titres ne se retrouvent pas seuls en bas de page
- Lignes alternées sur les tableaux
- Code inline et en blocs avec police monospace et fond discret
- Citations avec bordure bleue à gauche
- Format A4 avec marges adaptées
-243
View File
@@ -1,243 +0,0 @@
#!/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
@@ -1,21 +0,0 @@
{
"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://[email protected]/hykocx/markdown-to-pdf.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"marked": "^18.0.0",
"puppeteer": "^24.40.0"
}
}