Markdown vers PDF côté client : De html2pdf.js à l'impression native du navigateur
Convertir du Markdown en PDF dans le navigateur semble simple, mais c’est plein de pièges. Cet article documente les choix techniques, l’évolution architecturale et les détails d’implémentation derrière l’export PDF de md-to.com — de notre tentative initiale avec html2pdf.js à la migration finale vers l’impression native du navigateur, en passant par chaque leçon apprise en chemin.
Trois voies pour la génération PDF côté client
Sous la contrainte d’un frontend pur (sans serveur), il existe trois approches principales pour générer des PDF :
| Approche | Mécanisme | Bibliothèques |
|---|---|---|
| Rendu côté serveur | Lancer un navigateur headless en Node.js pour rendre le HTML et exporter en PDF | Puppeteer, Playwright, wkhtmltopdf |
| Génération JS côté client | Rendre le HTML en Canvas dans le navigateur, puis convertir en PDF | html2pdf.js, jsPDF + html2canvas |
| Impression native du navigateur | Appeler window.print(), l’utilisateur sélectionne « Enregistrer au format PDF » dans la boîte de dialogue d’impression | Aucune bibliothèque tierce nécessaire |
md-to.com est un site entièrement statique où toutes les conversions se font localement dans le navigateur sans dépendance backend. Cela exclut totalement les approches côté serveur, nous laissant le choix entre html2pdf.js et l’impression native du navigateur.
Version 1 : L’expérience html2pdf.js et pourquoi nous l’avons abandonné
Approche initiale
Nous avons initialement adopté html2pdf.js, qui fonctionne comme suit :
- Passer les éléments HTML à html2canvas pour les rendre en bitmap Canvas
- Utiliser jsPDF pour découper le Canvas en PDF multi-pages
- Déclencher un téléchargement navigateur
La configuration principale :
const opt = {
margin: [0.5, 0.5],
filename: pdfFilename,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['pre', 'code', 'blockquote', 'img', '.katex-display',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
},
};
await html2pdf().set(opt).from(wrapper).save();
La configuration pagebreak.avoid indique à html2pdf.js d’éviter les sauts de page à l’intérieur de ces éléments — en théorie, cela devrait résoudre le problème de troncature.
Problèmes rencontrés
L’utilisation réelle a révélé plusieurs problèmes critiques :
1. Sortie bitmap — le texte n’est pas sélectionnable
html2pdf.js fait essentiellement « HTML → capture d’écran Canvas → assemblage en PDF ». Chaque page est une image JPEG. Le texte dans le PDF ne peut être ni sélectionné ni recherché, et tous les hyperliens sont perdus. Pour de la documentation technique, c’est rédhibitoire.
2. La troncature aux sauts de page est difficile à corriger
Bien que pagebreak.avoid gère les cas simples, quand un bloc de code dépasse une page en hauteur ou que des listes imbriquées s’étendent sur plusieurs pages, l’algorithme de pagination de html2pdf.js coupe en plein milieu des éléments — car il calcule en fonction de la hauteur en pixels, contrairement au moteur de mise en page du navigateur qui comprend la structure du contenu.
3. Problèmes de performance et de mémoire
html2canvas doit re-rendre l’arbre DOM entier dans un Canvas. Pour les longs documents (10+ pages), cela consomme énormément de mémoire et peut même faire planter la page sur les appareils mobiles.
Version 2 : Impression native du navigateur
Pourquoi l’impression native
La fonction d’impression du navigateur invoque le moteur de mise en page du système d’exploitation, produisant des PDF vectoriels — le texte est sélectionnable et recherchable, les hyperliens restent cliquables, et les fichiers sont plus légers.
| Dimension | html2pdf.js | Impression native du navigateur |
|---|---|---|
| Format de sortie | Bitmap (capture Canvas) | Vectoriel (texte sélectionnable/recherchable) |
| Pagination | Calcul JS en pixels, sujet à troncature | CSS page-break-*, géré par le moteur du navigateur |
| Hyperliens | Perdus | Préservés |
| Performance | Forte consommation mémoire pour le rendu Canvas | Appelle le noyau d’impression système, extrêmement rapide |
| Maintenabilité | Préoccupante | Capacité native du navigateur, pérenne |
Le seul « inconvénient » est que les utilisateurs doivent manuellement sélectionner « Enregistrer au format PDF » dans la boîte de dialogue d’impression — cela nécessite un bon guidage UI.
Architecture principale : Overlay + IFrame
L’architecture globale comporte trois couches :
┌──────────────────────────────────┐
│ Barre d'outils │ ← Bouton imprimer, Bouton annuler
├──────────────────────────────────┤
│ │
│ ┌────────────────────┐ │
│ │ │ │
│ │ IFrame (Aperçu) │ │ ← Injecté avec HTML stylisé
│ │ width: 8.5in │ │
│ │ │ │
│ └────────────────────┘ │
│ │
│ Overlay (Plein écran) │ ← z-index: 70
└──────────────────────────────────┘
Flux de travail
- L’utilisateur clique sur le bouton « Télécharger PDF »
showPdfOverlay()est appelé, créant un overlay plein écran- Import dynamique du système de modèles et récupération du modèle actif
- Appel de
generatePrintStyles(template)pour générer les styles d’impression complets - Création d’un iframe et injection des styles + contenu HTML
- L’utilisateur prévisualise le résultat dans l’overlay, puis clique sur « Imprimer / Enregistrer en PDF »
iframe.contentWindow.print()déclenche la boîte de dialogue d’impression système
Pourquoi un IFrame
Appeler window.print() directement sur la page courante imprimerait la page entière (y compris l’éditeur, la barre latérale et les autres éléments UI). Utiliser un iframe permet de :
- Isoler le contenu d’impression au seul rendu Markdown
- Injecter des styles d’impression indépendants sans affecter la page principale
- Fournir un aperçu WYSIWYG — ce que vous voyez dans l’iframe est ce qui sera imprimé
Implémentation clé :
const styles = generatePrintStyles(template);
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
${styles}
</head>
<body>
${currentHtml}
</body>
</html>
`);
doc.close();
}
La largeur de l’iframe est définie à 8.5in (largeur du papier Letter), avec une hauteur calculée dynamiquement selon le contenu :
setTimeout(() => {
const bodyHeight = doc.body.scrollHeight;
iframe.style.height = Math.max(bodyHeight + 40, 800) + 'px';
}, 100);
Stratégies de pagination CSS en détail
Le cœur de l’approche d’impression native réside dans le CSS. Les règles @media print instruisent le moteur de mise en page du navigateur à suivre notre intention lors de la pagination.
Empêcher la troncature du contenu
@media print {
tr, pre, blockquote, img, .katex-display {
page-break-inside: avoid;
break-inside: avoid;
}
}
L’utilisation simultanée de page-break-inside (syntaxe legacy) et break-inside (syntaxe moderne) assure une compatibilité maximale. Les éléments couverts incluent :
pre: Blocs de codeblockquote: Citationsimg: Images.katex-display: Blocs de formules mathématiquestr: Lignes de tableau
Les tableaux s’étendent sur les pages mais les en-têtes se répètent
@media print {
table {
page-break-inside: auto;
}
thead {
display: table-header-group;
}
}
C’est une combinaison astucieuse : table est autorisé à s’étendre sur les pages (car les tableaux peuvent être très longs), mais thead défini sur table-header-group fait que le navigateur répète l’en-tête du tableau en haut de chaque page. C’est extrêmement utile pour les longs tableaux — plus besoin de revenir à la première page pour vérifier les noms de colonnes.
Les titres ne sont pas orphelins en bas de page
@media print {
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
}
Si un titre apparaît en bas de page avec son corps de texte commençant à la page suivante, l’expérience de lecture en souffre. page-break-after: avoid dit au navigateur : ne pas faire de saut de page après un titre — garder le titre et son contenu suivant sur la même page.
Préservation des couleurs d’arrière-plan
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
Les navigateurs n’impriment pas les couleurs d’arrière-plan par défaut (pour économiser l’encre). Ces deux lignes CSS forcent la préservation des couleurs d’arrière-plan — essentiel pour les arrière-plans des blocs de code, les bandes alternées des tableaux et les arrière-plans des citations. Les utilisateurs doivent encore cocher « Graphiques d’arrière-plan » dans la boîte de dialogue d’impression pour que cela prenne effet.
Système de modèles et génération dynamique de styles
md-to.com propose plus de 20 modèles de documents, chacun définissant un ensemble complet de paramètres visuels :
interface DocumentTemplate {
fonts: { body: string; heading: string; code: string };
fontSizes: { body: number; h1: number; h2: number; /* ... */ code: number };
colors: {
text: string; heading: string; link: string;
codeBackground: string; codeText: string; codeBorder: string;
quoteBorder: string; quoteBackground: string; quoteText: string;
tableBorder: string; tableHeaderBg: string; tableHeaderText: string;
tableRowOdd: string; tableRowEven: string;
};
spacing: {
lineHeight: number;
headingBefore: number; headingAfter: number;
paragraphBefore: number; paragraphAfter: number;
};
}
La fonction generatePrintStyles(template) injecte les paramètres du modèle dans un template CSS, générant une balise <style> complète. Cela signifie que le même contenu Markdown produit un PDF totalement différent lorsque vous changez de modèle — polices, couleurs et espacements suivent tous le modèle.
Les styles de coloration syntaxique (highlight.js) sont aussi intégrés dans le CSS généré, assurant que les blocs de code dans le PDF conservent la coloration syntaxique.
Génération intelligente des noms de fichiers
Les noms de fichiers PDF téléchargés ne sont pas un générique download.pdf. La fonction getDownloadFileName() extrait le premier titre du source Markdown :
export function getDownloadFileName(
mdText: string,
extension: string,
fallbackPrefix: string = 'markdown-export',
): string {
const lines = mdText.split(/\r?\n/);
const headingLine = lines.find((line) => /^\s{0,3}#{1,6}\s+/.test(line));
if (headingLine) {
const raw = headingLine
.replace(/^\s{0,3}#{1,6}\s+/, '')
.replace(/\s+#*\s*$/, '');
const cleaned = sanitizeFilename(raw);
if (cleaned) return `${cleaned}.${extension}`;
}
return `${fallbackPrefix}-${getDateStamp()}.${extension}`;
}
La logique :
- Diviser le texte Markdown par lignes et trouver la première ligne correspondant au format
# Titre - Retirer le préfixe
#et toute décoration#finale - Nettoyer les caractères illégaux (
\ / : * ? " < > |) viasanitizeFilename(), normaliser les espaces et tronquer à 80 caractères - Si aucun titre n’est trouvé, se rabattre sur le format
markdown-to-pdf-20260311.pdf
Par exemple, si un document commence par # Proposition technique du projet, le fichier téléchargé se nommera Proposition technique du projet.pdf.
Leçons apprises
1. Bug d’état du bouton html2pdf.js
Après qu’html2pdf.js ait terminé un téléchargement, l’état disabled du bouton n’était pas réinitialisé à false. Après le premier téléchargement réussi, le bouton restait grisé. Le chemin de succès ne restaurait que le texte du bouton mais pas l’attribut disabled :
// Chemin de succès — il manque btnDownloadPdf.disabled = false
await html2pdf().set(opt).from(wrapper).save();
showToast(texts.downloadStarted);
btnDownloadPdf.innerText = texts.downloadPdf;
Tandis que le chemin d’erreur le gérait correctement :
// Chemin d'erreur — correctement restauré
btnDownloadPdf.disabled = false;
btnDownloadPdf.innerText = texts.downloadPdf;
2. innerHTML causant un risque XSS
Une version antérieure utilisait tipText.innerHTML = texts.tip pour injecter le texte d’info-bulle. Bien que texts.tip provienne de la configuration i18n et non de l’entrée utilisateur, innerHTML est en soi une API dangereuse. Après revue de code, il a été changé en textContent :
// Avant : tipText.innerHTML = texts.tip;
tipText.textContent = texts.tip;
3. Abus de z-index
Le z-index de l’overlay était initialement défini à 99999, avec la barre d’outils à 100000. Cette approche par force brute entre facilement en conflit avec d’autres composants dans les pages complexes. Après optimisation, nous sommes passés à un empilement sémantique — l’overlay utilise z-index: 70, et la barre d’outils utilise position: relative pour se superposer naturellement au-dessus du contenu de l’overlay sans avoir besoin de son propre z-index.
4. Stratégie de migration graduelle
Après la migration vers l’impression native, nous n’avons pas supprimé le code html2pdf.js. Au lieu de cela, nous avons masqué le bouton via des commentaires :
// toolbar.appendChild(btnDownloadPdf); Le téléchargement a des bugs, bouton masqué pour l'instant
toolbar.appendChild(btnPrint);
La logique de téléchargement html2pdf.js, la configuration et les gestionnaires d’événements sont tous conservés — seul le point d’entrée est masqué. L’avantage : si nous avons un jour besoin de fournir un « téléchargement en un clic » (sans nécessiter d’interaction utilisateur dans la boîte de dialogue d’impression), nous pouvons le restaurer rapidement.
Guider les utilisateurs dans les paramètres d’impression
L’expérience utilisateur de l’impression native du navigateur dépend des paramètres de la boîte de dialogue d’impression. Un bon guidage UI est essentiel :
- Destination : Sélectionnez « Enregistrer au format PDF »
- Graphiques d’arrière-plan : Doit être coché — sinon les arrière-plans des blocs de code, les bandes alternées des tableaux et les arrière-plans des citations sont tous perdus
- En-têtes et pieds de page : Décochez — supprimez l’URL et la date inutiles en haut du PDF
- Taille du papier : A4 (courant internationalement) ou Letter (courant en Amérique du Nord)
- Marges : Choisissez « Par défaut » — le CSS définit déjà
padding: 20mm
Conclusion
En rétrospective sur l’évolution technique complète :
- html2pdf.js a résolu le problème de la « génération PDF côté client », mais la qualité de sortie (bitmap, troncature, texte non recherchable) n’était pas suffisante
- L’impression native du navigateur exploite le moteur de mise en page du système d’exploitation pour produire des PDF vectoriels avec pagination contrôlée par CSS, améliorant considérablement la qualité de sortie
- L’architecture Overlay + IFrame isole le contenu d’impression de l’UI de la page, offrant une expérience d’aperçu WYSIWYG
- Les règles de pagination CSS sont le cœur de la solution —
page-break-inside: avoid,table-header-group, etpage-break-after: avoidrésolvent les trois points de douleur majeurs : troncature du code, perte des en-têtes de tableau et titres orphelins
Si vous construisez une fonctionnalité similaire d’export PDF frontend, envisagez d’abord l’impression native du navigateur. Elle ne nécessite aucune dépendance tierce, produit la meilleure qualité de sortie, et ne deviendra jamais obsolète — car l’impression est une capacité fondamentale du navigateur.
Liens associés :
Outils associés
Convertir MD en PDF en ligne - Convertisseur Markdown vers PDF gratuit
Convertissez Markdown en PDF instantanément. Convertisseur en ligne gratuit MD vers PDF avec plus de 20 modèles professionnels. Parfait pour les rapports et la documentation. Essayez maintenant !
Convertisseur Markdown vers LaTeX gratuit - Convertir MD en TeX en ligne
Convertissez Markdown en LaTeX en ligne gratuitement. Transformez vos documents MD en format TeX pour les articles académiques. Sans inscription !