diff --git a/app/components/MarkdownModal.tsx b/app/components/MarkdownModal.tsx
index f76a65a..7a3cbbe 100644
--- a/app/components/MarkdownModal.tsx
+++ b/app/components/MarkdownModal.tsx
@@ -1,15 +1,126 @@
// components/MarkdownModal.tsx
-import { X } from "lucide-react";
+import { X, Download } from "lucide-react";
+import { jsPDF } from "jspdf";
+import { useState } from "react";
interface HtmlModalProps {
- content: string | null;
+ content: string[] | null;
onClose: () => void;
}
+const renderHtmlOnPdfPage = (pdf: jsPDF, htmlContent: string) => {
+ const pageHeight = pdf.internal.pageSize.getHeight();
+ const pageWidth = pdf.internal.pageSize.getWidth();
+ const margin = 15;
+ let cursorY = margin;
+
+ const container = document.createElement("div");
+ container.innerHTML = htmlContent;
+
+ const processNode = (
+ node: ChildNode,
+ currentStyle: { fontStyle: "normal" | "bold" | "italic"; fontSize: number }
+ ) => {
+ if (cursorY > pageHeight - margin) {
+ pdf.addPage();
+ cursorY = margin;
+ }
+
+ const nextStyle = { ...currentStyle };
+ let spacingAfter = 0;
+
+ switch (node.nodeName) {
+ case "H1":
+ nextStyle.fontSize = 22;
+ nextStyle.fontStyle = "bold";
+ spacingAfter = 8;
+ break;
+ case "H2":
+ nextStyle.fontSize = 18;
+ nextStyle.fontStyle = "bold";
+ spacingAfter = 6;
+ break;
+ case "H3":
+ nextStyle.fontSize = 14;
+ nextStyle.fontStyle = "bold";
+ spacingAfter = 4;
+ break;
+ case "P":
+ nextStyle.fontSize = 10;
+ nextStyle.fontStyle = "normal";
+ spacingAfter = 4;
+ break;
+ case "UL":
+ spacingAfter = 4;
+ break;
+ case "STRONG":
+ case "B":
+ nextStyle.fontStyle = "bold";
+ break;
+ }
+
+ if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
+ pdf.setFontSize(nextStyle.fontSize);
+ pdf.setFont("Helvetica", nextStyle.fontStyle);
+ const textLines = node.textContent.split("\n");
+ textLines.forEach((line) => {
+ if (line.trim() === "") {
+ cursorY += nextStyle.fontSize * 0.4;
+ return;
+ }
+ const splitText = pdf.splitTextToSize(line, pageWidth - margin * 2);
+ pdf.text(splitText, margin, cursorY);
+ cursorY += splitText.length * nextStyle.fontSize * 0.4;
+ });
+ } else if (node.nodeName === "UL") {
+ Array.from((node as HTMLUListElement).children).forEach((li) => {
+ const liText = `• ${li.textContent?.trim() || ""}`;
+ const lines = pdf.splitTextToSize(liText, pageWidth - margin * 2 - 5);
+ pdf.setFontSize(10);
+ pdf.setFont("Helvetica", "normal");
+ pdf.text(lines, margin + 5, cursorY);
+ cursorY += lines.length * 10 * 0.4 + 2;
+ });
+ }
+
+ if (node.childNodes.length > 0) {
+ node.childNodes.forEach((child) => processNode(child, nextStyle));
+ }
+ cursorY += spacingAfter;
+ };
+
+ container.childNodes.forEach((node) =>
+ processNode(node, { fontStyle: "normal", fontSize: 10 })
+ );
+};
+
export default function MarkdownModal({ content, onClose }: HtmlModalProps) {
+ const [isDownloading, setIsDownloading] = useState(false);
if (!content) return null;
+ const handleDownloadPdf = async () => {
+ if (!content || content.length === 0) return;
+ setIsDownloading(true);
+ try {
+ const pdf = new jsPDF({ orientation: "p", unit: "mm", format: "a4" });
+ pdf.setFont("Helvetica", "normal");
+ content.forEach((pageHtml, index) => {
+ if (index > 0) pdf.addPage();
+ renderHtmlOnPdfPage(pdf, pageHtml);
+ });
+ pdf.save("document_anonymise.pdf");
+ } catch (error) {
+ console.error("Erreur lors de la génération du PDF:", error);
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ const previewHtml = content.join(
+ '
'
+ );
+
return (
Aperçu du Document
-
+
+
+
+
-
- {/* Ce conteneur stylise le HTML propre qu'il reçoit */}
diff --git a/app/page.tsx b/app/page.tsx
index 00b34f8..a1a994e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,86 +6,163 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
-import {
- Upload, FileText, ShieldCheck, Download, Trash2, AlertCircle, X, Zap, Lock, Shield, Clock,
- User, AtSign, MapPin, Cake, Home as HomeIcon, Venus, Phone, Building, Fingerprint, CreditCard, Check,
- Eye // <-- Icône ajoutée
+import {
+ Upload,
+ FileText,
+ ShieldCheck,
+ Download,
+ Trash2,
+ AlertCircle,
+ X,
+ Zap,
+ Lock,
+ Shield,
+ Clock,
+ User,
+ AtSign,
+ MapPin,
+ Cake,
+ Home as HomeIcon,
+ Venus,
+ Phone,
+ Building,
+ Fingerprint,
+ CreditCard,
+ Check,
+ Eye,
} from "lucide-react";
-import MarkdownModal from "./components/MarkdownModal"; // <-- Composant de la modale ajouté
+import MarkdownModal from "./components/MarkdownModal";
// === Interfaces et Données ===
-
-// L'interface est mise à jour pour inclure le contenu texte pour l'aperçu
interface ProcessedFile {
- id: string; name: string; status: 'processing' | 'completed' | 'error';
- timestamp: Date; originalSize?: string; processedSize?: string;
- piiCount?: number; errorMessage?: string; processedBlob?: Blob;
- textContent?: string; // <-- Ajouté pour stocker le contenu du fichier
+ id: string;
+ name: string;
+ status: "processing" | "completed" | "error";
+ timestamp: Date;
+ originalSize?: string;
+ processedSize?: string;
+ piiCount?: number;
+ errorMessage?: string;
+ processedBlob?: Blob;
+ // textContent est maintenant un tableau de strings, une par page.
+ textContent?: string[];
}
-interface AnonymizationOptions { [key: string]: boolean; }
+interface AnonymizationOptions {
+ [key: string]: boolean;
+}
const piiOptions = [
- { id: 'name', label: 'Nom & Prénom', icon: User },
- { id: 'email', label: 'Email', icon: AtSign },
- { id: 'location', label: 'Lieu', icon: MapPin },
- { id: 'birthdate', label: 'Date de naissance', icon: Cake },
- { id: 'address', label: 'Adresse', icon: HomeIcon },
- { id: 'gender', label: 'Genre / Sexe', icon: Venus },
- { id: 'phone', label: 'Téléphone', icon: Phone },
- { id: 'organization', label: 'Entreprise', icon: Building },
- { id: 'idNumber', label: 'N° Identification', icon: Fingerprint },
- { id: 'financial', label: 'Données financières', icon: CreditCard },
+ { id: "name", label: "Nom & Prénom", icon: User },
+ { id: "email", label: "Email", icon: AtSign },
+ { id: "location", label: "Lieu", icon: MapPin },
+ { id: "birthdate", label: "Date de naissance", icon: Cake },
+ { id: "address", label: "Adresse", icon: HomeIcon },
+ { id: "gender", label: "Genre / Sexe", icon: Venus },
+ { id: "phone", label: "Téléphone", icon: Phone },
+ { id: "organization", label: "Entreprise", icon: Building },
+ { id: "idNumber", label: "N° Identification", icon: Fingerprint },
+ { id: "financial", label: "Données financières", icon: CreditCard },
];
export default function Home() {
- // === Déclaration des états (States) ===
const [file, setFile] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [isDragOver, setIsDragOver] = useState(false);
const [history, setHistory] = useState([]);
const [error, setError] = useState(null);
- const [anonymizationOptions, setAnonymizationOptions] = useState(
- piiOptions.reduce((acc, option) => ({ ...acc, [option.id]: true }), {})
- );
- // Nouvel état pour gérer la modale
- const [modalContent, setModalContent] = useState(null);
+ const [anonymizationOptions, setAnonymizationOptions] =
+ useState(
+ piiOptions.reduce((acc, option) => ({ ...acc, [option.id]: true }), {})
+ );
+ // L'état de la modale attend maintenant un tableau de strings ou null
+ const [modalContent, setModalContent] = useState(null);
- // === Fonctions utilitaires et Handlers ===
- const handleOptionChange = (id: string) => setAnonymizationOptions(prev => ({ ...prev, [id]: !prev[id] }));
- const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files?.length) { setFile(event.target.files[0]); setError(null); }};
- const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (e.dataTransfer.files?.length) { setFile(e.dataTransfer.files[0]); setError(null); }}, []);
- const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }, []);
- const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }, []);
- const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; };
- const clearFile = () => { setFile(null); setError(null); };
+ const handleOptionChange = (id: string) =>
+ setAnonymizationOptions((prev) => ({ ...prev, [id]: !prev[id] }));
+ const handleFileChange = (event: React.ChangeEvent) => {
+ if (event.target.files?.length) {
+ setFile(event.target.files[0]);
+ setError(null);
+ }
+ };
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ if (e.dataTransfer.files?.length) {
+ setFile(e.dataTransfer.files[0]);
+ setError(null);
+ }
+ }, []);
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(true);
+ }, []);
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ }, []);
+ const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+ const clearFile = () => {
+ setFile(null);
+ setError(null);
+ };
const clearHistory = () => setHistory([]);
- const removeFromHistory = (id: string) => setHistory(prev => prev.filter(item => item.id !== id));
-
- const handleDownload = (id: string) => {
- const fileToDownload = history.find(item => item.id === id);
- if (!fileToDownload?.processedBlob) return;
- const url = URL.createObjectURL(fileToDownload.processedBlob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `anonymized_${fileToDownload.name.split('.')[0] || 'file'}.txt`;
- a.click();
- URL.revokeObjectURL(url);
- a.remove();
+ const removeFromHistory = (id: string) =>
+ setHistory((prev) => prev.filter((item) => item.id !== id));
+
+ const handleDownload = (id: string) => {
+ const fileToDownload = history.find((item) => item.id === id);
+ if (!fileToDownload?.processedBlob) return;
+ const url = URL.createObjectURL(fileToDownload.processedBlob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `anonymized_${
+ fileToDownload.name.split(".")[0] || "file"
+ }.txt`;
+ a.click();
+ URL.revokeObjectURL(url);
+ a.remove();
};
- // Nouvelle fonction pour gérer l'ouverture de l'aperçu
+ // Met à jour le contenu de la modale avec le tableau de pages
const handlePreview = (id: string) => {
const fileToPreview = history.find((item) => item.id === id);
if (fileToPreview?.textContent) {
setModalContent(fileToPreview.textContent);
}
};
-
- const getStatusInfo = (item: ProcessedFile) => { switch (item.status) { case 'completed': return { icon: , color: 'bg-[#061717]' }; case 'error': return { icon: , color: 'bg-[#F7AB6E]' }; default: return { icon: , color: 'bg-[#061717]' }; } };
- // === Fonction principale pour l'appel à n8n ===
+ const getStatusInfo = (item: ProcessedFile) => {
+ switch (item.status) {
+ case "completed":
+ return {
+ icon: ,
+ color: "bg-[#061717]",
+ };
+ case "error":
+ return {
+ icon: ,
+ color: "bg-[#F7AB6E]",
+ };
+ default:
+ return {
+ icon: (
+
+ ),
+ color: "bg-[#061717]",
+ };
+ }
+ };
+
+ // === Fonction principale mise à jour pour gérer le format JSON ===
const processFile = async () => {
if (!file) return;
const n8nWebhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
@@ -94,125 +171,397 @@ export default function Home() {
return;
}
- setIsProcessing(true); setProgress(0); setError(null);
+ setIsProcessing(true);
+ setProgress(0);
+ setError(null);
const fileId = `${Date.now()}-${file.name}`;
- setHistory(prev => [{ id: fileId, name: file.name, status: 'processing', timestamp: new Date(), originalSize: formatFileSize(file.size) }, ...prev]);
+ setHistory((prev) => [
+ {
+ id: fileId,
+ name: file.name,
+ status: "processing",
+ timestamp: new Date(),
+ originalSize: formatFileSize(file.size),
+ },
+ ...prev,
+ ]);
setFile(null);
setProgress(10);
try {
const formData = new FormData();
- formData.append('file', file);
- formData.append('options', JSON.stringify(anonymizationOptions));
-
+ formData.append("file", file);
+ formData.append("options", JSON.stringify(anonymizationOptions));
+
setProgress(30);
- const response = await fetch(n8nWebhookUrl, { method: 'POST', body: formData });
+ const response = await fetch(n8nWebhookUrl, {
+ method: "POST",
+ body: formData,
+ });
setProgress(70);
if (!response.ok) {
- const errorResult = await response.json().catch(() => ({ error: `Erreur serveur [${response.status}]` }));
+ const errorResult = await response
+ .json()
+ .catch(() => ({ error: `Erreur serveur [${response.status}]` }));
throw new Error(errorResult.error || `Échec du traitement.`);
}
- const processedBlob = await response.blob();
- const piiCount = parseInt(response.headers.get('X-Pii-Count') || '0', 10);
-
- // On lit le contenu du blob en texte pour pouvoir l'afficher
- const textContent = await processedBlob.text();
- setProgress(90);
-
- // On met à jour l'historique avec toutes les informations, y compris le contenu texte
- setHistory(prev => prev.map(item => item.id === fileId ? { ...item, status: 'completed', processedSize: formatFileSize(processedBlob.size), piiCount, processedBlob, textContent } : item));
- setProgress(100);
+ // On parse la réponse JSON au lieu de lire un blob
+ const result = await response.json();
+ // Validation de la structure de la réponse
+ if (
+ !result.anonymizedDocument ||
+ !Array.isArray(result.anonymizedDocument.pages)
+ ) {
+ throw new Error(
+ "Format de réponse invalide du service d'anonymisation."
+ );
+ }
+
+ const textContent: string[] = result.anonymizedDocument.pages;
+ const piiCount: number = result.anonymizedDocument.piiCount || 0;
+
+ // On crée un blob pour le téléchargement .txt en joignant les pages
+ const fullText = textContent.join("\n\n--- Page Suivante ---\n\n");
+ const processedBlob = new Blob([fullText], {
+ type: "text/plain;charset=utf-8",
+ });
+ setProgress(90);
+
+ // On met à jour l'historique avec le tableau de pages
+ setHistory((prev) =>
+ prev.map((item) =>
+ item.id === fileId
+ ? {
+ ...item,
+ status: "completed",
+ processedSize: formatFileSize(processedBlob.size),
+ piiCount,
+ processedBlob,
+ textContent,
+ }
+ : item
+ )
+ );
+ setProgress(100);
} catch (err) {
- const errorMessage = err instanceof Error ? err.message : "Une erreur inconnue est survenue.";
+ const errorMessage =
+ err instanceof Error
+ ? err.message
+ : "Une erreur inconnue est survenue.";
setError(errorMessage);
- setHistory(prev => prev.map(item => item.id === fileId ? { ...item, status: 'error', errorMessage } : item));
+ setHistory((prev) =>
+ prev.map((item) =>
+ item.id === fileId ? { ...item, status: "error", errorMessage } : item
+ )
+ );
} finally {
- setIsProcessing(false); setTimeout(() => setProgress(0), 1000);
+ setIsProcessing(false);
+ setTimeout(() => setProgress(0), 1000);
}
};
- // === Rendu du composant (JSX) ===
+ // === Rendu du composant (JSX - inchangé) ===
return (
- {/* --- Sidebar (Historique) --- */}
- {/* --- Contenu Principal --- */}
-
-
+
+
-
{file ? : }
- {file ? (
{file.name}
{formatFileSize(file.size)}
) : (
Glissez votre document
Ou cliquez ici
)}
+
+ {file ? (
+
+ ) : (
+
+ )}
+
+ {file ? (
+
+
+ {file.name}
+
+
+ {formatFileSize(file.size)}
+
+
+ ) : (
+
+
+ Glissez votre document
+
+
+ Ou cliquez ici
+
+
+ )}
-
Options d'Anonymisation
+
+ Options d'Anonymisation
+
- {isProcessing && (
Traitement...{Math.round(progress)}%
)}
- {error && (
)}
+ {isProcessing && (
+
+
+
+ Traitement...
+
+
+ {Math.round(progress)}%
+
+
+
+
+ )}
+ {error && (
+
+ )}
-
- {file && !isProcessing &&
}
+
+ {file && !isProcessing && (
+
+ )}
- {[{ icon: Shield, title: "RGPD", subtitle: "Conforme" }, { icon: Clock, title: "Rapide", subtitle: "Local" }, { icon: Lock, title: "Sécurisé", subtitle: "Sans Serveur" }].map((item, index) => (
{item.title}
{item.subtitle}
))}
+ {[
+ { icon: Shield, title: "RGPD", subtitle: "Conforme" },
+ { icon: Clock, title: "Rapide", subtitle: "Local" },
+ { icon: Lock, title: "Sécurisé", subtitle: "Sans Serveur" },
+ ].map((item, index) => (
+
+
+
+
+
+ {item.title}
+
+
+ {item.subtitle}
+
+
+ ))}
- {/* Le composant de la modale est rendu ici, en dehors du flux principal */}
- setModalContent(null)}
+ setModalContent(null)}
/>
);
-}
\ No newline at end of file
+}
diff --git a/package-lock.json b/package-lock.json
index 8e79bce..e04dac5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,8 @@
"@ungap/with-resolvers": "^0.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "html2canvas": "^1.4.1",
+ "jspdf": "^3.0.1",
"lucide-react": "^0.514.0",
"mammoth": "^1.9.1",
"next": "15.3.3",
@@ -67,6 +69,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.27.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
+ "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -1719,6 +1730,13 @@
"pdfjs-dist": "*"
}
},
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/react": {
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
@@ -1738,6 +1756,13 @@
"@types/react": "^19.0.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2583,6 +2608,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "license": "(MIT OR Apache-2.0)",
+ "bin": {
+ "atob": "bin/atob.js"
+ },
+ "engines": {
+ "node": ">= 4.5.0"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2636,6 +2673,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2697,6 +2743,18 @@
"node": ">=8"
}
},
+ "node_modules/btoa": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
+ "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
+ "license": "(MIT OR Apache-2.0)",
+ "bin": {
+ "btoa": "bin/btoa.js"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -2812,6 +2870,26 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -3023,6 +3101,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/core-js": {
+ "version": "3.43.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz",
+ "integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -3043,6 +3133,15 @@
"node": ">= 8"
}
},
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3268,6 +3367,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
+ "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
@@ -4052,6 +4161,12 @@
"node": "^12.20 || >= 14.13"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4494,6 +4609,19 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/human-signals": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz",
@@ -5200,6 +5328,24 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jspdf": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz",
+ "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.7",
+ "atob": "^2.1.2",
+ "btoa": "^1.2.1",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.11",
+ "core-js": "^3.6.0",
+ "dompurify": "^3.2.4",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7217,6 +7363,13 @@
"@napi-rs/canvas": "^0.1.67"
}
},
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7371,6 +7524,16 @@
],
"license": "MIT"
},
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -7463,6 +7626,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7642,6 +7812,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8008,6 +8188,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
"node_modules/stdin-discarder": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz",
@@ -8298,6 +8488,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
@@ -8343,6 +8543,15 @@
"node": ">=18"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -8747,6 +8956,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
diff --git a/package.json b/package.json
index fc62efd..0cf2bf7 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,8 @@
"@ungap/with-resolvers": "^0.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "html2canvas": "^1.4.1",
+ "jspdf": "^3.0.1",
"lucide-react": "^0.514.0",
"mammoth": "^1.9.1",
"next": "15.3.3",