Compare commits
1
Commits
main
..
7cc0aa5d81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc0aa5d81 |
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
output/
|
|
||||||
@@ -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
@@ -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}`);
|
|
||||||
})();
|
|
||||||
Generated
-1150
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user