dc1f115bd6
export-pdf-button et print-button injectaient marked(content) directement dans innerHTML / document.write. Un lien Markdown javascript: passait le filtre hasRestrictedChar et pouvait s'exécuter. Ajout de DOMPurify.sanitize() via import dynamique (déjà présent en dep transitive de jspdf) sur les deux composants, avec whitelist de tags et d'attributs stricte. markdown-renderer n'est pas touché car react-markdown-preview utilise rehype-sanitize en interne. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
323 lines
10 KiB
JavaScript
323 lines
10 KiB
JavaScript
'use client'
|
|
|
|
import {useState} from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import Button from '@mui/material/Button'
|
|
import CircularProgress from '@mui/material/CircularProgress'
|
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
|
import {formatDate} from '@/lib/format.js'
|
|
|
|
// Helper function to render markdown to HTML
|
|
const renderMarkdownToHtml = async content => {
|
|
if (!content) {
|
|
return 'Aucun contenu disponible'
|
|
}
|
|
|
|
// Check if content contains markdown syntax
|
|
const hasMarkdown = content.includes('**')
|
|
|| content.includes('*')
|
|
|| content.includes('#')
|
|
|| content.includes('[')
|
|
|| content.includes('`')
|
|
|| content.includes('> ')
|
|
|| content.includes('- ')
|
|
|| content.includes('1. ')
|
|
|
|
if (!hasMarkdown) {
|
|
// Simple text with line breaks
|
|
return content.replaceAll('\n', '<br>')
|
|
}
|
|
|
|
try {
|
|
// Dynamic import of markdown parser and sanitizer
|
|
const [{marked}, {default: DOMPurify}] = await Promise.all([
|
|
import('marked'),
|
|
import('dompurify')
|
|
])
|
|
// Configure marked for better PDF rendering
|
|
marked.setOptions({
|
|
breaks: true, // Convert \n to <br>
|
|
gfm: true, // GitHub flavored markdown
|
|
headerIds: false, // No header IDs needed for PDF
|
|
mangle: false // Don't mangle email addresses
|
|
})
|
|
|
|
return DOMPurify.sanitize(marked(content), {
|
|
ALLOWED_TAGS: [
|
|
'p',
|
|
'strong',
|
|
'em',
|
|
'b',
|
|
'i',
|
|
'ul',
|
|
'ol',
|
|
'li',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'blockquote',
|
|
'code',
|
|
'pre',
|
|
'br',
|
|
'hr',
|
|
'a',
|
|
'table',
|
|
'thead',
|
|
'tbody',
|
|
'tr',
|
|
'th',
|
|
'td',
|
|
],
|
|
ALLOWED_ATTR: [
|
|
'href',
|
|
'target',
|
|
'rel',
|
|
],
|
|
ALLOW_DATA_ATTR: false,
|
|
})
|
|
} catch (error) {
|
|
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
|
return content.replaceAll('\n', '<br>')
|
|
}
|
|
}
|
|
|
|
export default function ExportPdfButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
|
|
const [isExporting, setIsExporting] = useState(false)
|
|
|
|
const handleExportPdf = async () => {
|
|
setIsExporting(true)
|
|
|
|
try {
|
|
const {default: jsPDF} = await import('jspdf')
|
|
const {default: html2canvas} = await import('html2canvas')
|
|
|
|
// Créer un élément temporaire avec le contenu formaté
|
|
const tempDiv = document.createElement('div')
|
|
tempDiv.style.cssText = `
|
|
padding: 40px;
|
|
font-family: 'Roboto', Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
background-color: white;
|
|
width: 210mm;
|
|
position: fixed;
|
|
top: -9999px;
|
|
left: -9999px;
|
|
z-index: -1;
|
|
`
|
|
|
|
// Add CSS styles for markdown elements
|
|
const styleElement = document.createElement('style')
|
|
styleElement.textContent = `
|
|
.pdf-content h1, .pdf-content h2, .pdf-content h3, .pdf-content h4, .pdf-content h5, .pdf-content h6 {
|
|
margin: 20px 0 10px 0;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
.pdf-content h1 { font-size: 24px; color: #1976d2; }
|
|
.pdf-content h2 { font-size: 20px; }
|
|
.pdf-content h3 { font-size: 18px; }
|
|
.pdf-content h4 { font-size: 16px; }
|
|
.pdf-content p { margin: 10px 0; }
|
|
.pdf-content strong, .pdf-content b { font-weight: bold; }
|
|
.pdf-content em, .pdf-content i { font-style: italic; }
|
|
.pdf-content ul, .pdf-content ol {
|
|
margin: 10px 0;
|
|
padding-left: 25px;
|
|
}
|
|
.pdf-content li { margin: 5px 0; }
|
|
.pdf-content blockquote {
|
|
margin: 15px 0;
|
|
padding: 10px 20px;
|
|
border-left: 4px solid #1976d2;
|
|
background-color: #f5f5f5;
|
|
font-style: italic;
|
|
}
|
|
.pdf-content code {
|
|
background-color: #f5f5f5;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
}
|
|
.pdf-content pre {
|
|
background-color: #f5f5f5;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
overflow-x: auto;
|
|
margin: 15px 0;
|
|
}
|
|
.pdf-content pre code {
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
.pdf-content a { color: #1976d2; text-decoration: underline; }
|
|
.pdf-content hr {
|
|
border: none;
|
|
border-top: 1px solid #ccc;
|
|
margin: 20px 0;
|
|
}
|
|
.pdf-content table {
|
|
border-collapse: collapse;
|
|
margin: 15px 0;
|
|
width: 100%;
|
|
}
|
|
.pdf-content th, .pdf-content td {
|
|
border: 1px solid #ccc;
|
|
padding: 8px;
|
|
text-align: left;
|
|
}
|
|
.pdf-content th {
|
|
background-color: #f5f5f5;
|
|
font-weight: bold;
|
|
}
|
|
`
|
|
document.head.append(styleElement)
|
|
|
|
const authorName = versionData.user_created?.split('-')[0] || 'Système'
|
|
const createdAt = new Date(versionData.date_created)
|
|
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
|
const isExpired = createdAt < threeDaysAgo
|
|
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
|
|
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
|
|
|
|
// Vote counts display
|
|
const voteTotal = voteCounts ? voteCounts.total : 0
|
|
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
|
|
const voteTotalSign = voteTotal >= 0 ? '+' : ''
|
|
|
|
// Render markdown content to HTML
|
|
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
|
|
|
tempDiv.innerHTML = `
|
|
<div style="margin-bottom: 30px; border-bottom: 2px solid #1976d2; padding-bottom: 20px;">
|
|
<h1 style="color: #1976d2; margin: 0 0 10px 0; font-size: 28px; font-weight: bold;">
|
|
${versionData.name}
|
|
</h1>
|
|
<div style="margin-top: 15px;">
|
|
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
|
<strong>Auteur :</strong> @${authorName}
|
|
</p>
|
|
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
|
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
|
|
</p>
|
|
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
|
<strong>Statut du vote :</strong>
|
|
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
|
|
</p>
|
|
${voteCounts ? `
|
|
<div style="margin-top: 15px; padding: 15px; background-color: #f5f5f5; border-radius: 8px;">
|
|
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
|
|
📊 Résultats des votes
|
|
</p>
|
|
<p style="margin: 5px 0; font-size: 14px;">
|
|
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
|
|
</p>
|
|
<p style="margin: 5px 0; font-size: 14px;">
|
|
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
|
|
</p>
|
|
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
|
|
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
|
|
</p>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
<h2 style="color: #333; font-size: 20px; margin-bottom: 15px; font-weight: bold;">
|
|
Contenu
|
|
</h2>
|
|
<div class="pdf-content" style="font-size: 14px; line-height: 1.8; text-align: justify;">
|
|
${renderedContent}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
|
|
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
|
</div>
|
|
`
|
|
|
|
document.body.append(tempDiv)
|
|
|
|
// Générer le canvas avec une meilleure qualité
|
|
const canvas = await html2canvas(tempDiv, {
|
|
scale: 2,
|
|
useCORS: true,
|
|
allowTaint: true,
|
|
backgroundColor: '#ffffff',
|
|
width: tempDiv.scrollWidth,
|
|
height: tempDiv.scrollHeight
|
|
})
|
|
|
|
tempDiv.remove()
|
|
styleElement.remove() // Clean up styles
|
|
|
|
// Créer le PDF
|
|
const imgData = canvas.toDataURL('image/png', 1)
|
|
const pdf = new jsPDF('p', 'mm', 'a4') // eslint-disable-line new-cap
|
|
|
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
|
const imgWidth = pageWidth
|
|
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
|
|
|
let heightLeft = imgHeight
|
|
let position = 0
|
|
|
|
// Ajouter la première page
|
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
|
heightLeft -= pageHeight
|
|
|
|
// Ajouter des pages supplémentaires si nécessaire
|
|
while (heightLeft >= 0) {
|
|
position = heightLeft - imgHeight
|
|
pdf.addPage()
|
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
|
heightLeft -= pageHeight
|
|
}
|
|
|
|
// Nom du fichier sécurisé
|
|
const fileName = `${versionData.name.replaceAll(/[^a-zA-Z\d\s]/g, '').replaceAll(/\s+/g, '_')}_${formatDate(new Date(), 'yyyy-MM-dd')}.pdf`
|
|
pdf.save(fileName)
|
|
} catch (error) {
|
|
console.error('Erreur lors de l\'export PDF:', error)
|
|
} finally {
|
|
setIsExporting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant={variant}
|
|
size={size}
|
|
startIcon={isExporting ? <CircularProgress size={16} /> : <PictureAsPdfIcon />}
|
|
disabled={isExporting}
|
|
sx={{
|
|
minWidth: size === 'small' ? 'auto' : undefined,
|
|
'&:disabled': {
|
|
opacity: 0.6
|
|
}
|
|
}}
|
|
onClick={handleExportPdf}
|
|
>
|
|
{isExporting ? 'Export...' : (size === 'small' ? 'PDF' : 'Exporter PDF')}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
ExportPdfButton.propTypes = {
|
|
versionData: PropTypes.object.isRequired,
|
|
isOutdated: PropTypes.bool,
|
|
voteCounts: PropTypes.shape({
|
|
positive: PropTypes.number,
|
|
negative: PropTypes.number,
|
|
total: PropTypes.number
|
|
}),
|
|
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
|
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
|
}
|