perf: export pdf

This commit is contained in:
JOYCEQL
2024-12-20 15:01:14 +08:00
committed by qingchen
parent 5a82130b4b
commit 37353b06ea
5 changed files with 161 additions and 72 deletions
+1
View File
@@ -53,6 +53,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-resizable-panels": "^2.0.20",
"sonner": "^1.7.1",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
+4 -3
View File
@@ -1,18 +1,18 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Providers } from "./providers";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "魔法简历",
description: "极度自由的在线简历编辑器"
description: "极度自由的在线简历编辑器",
};
export default function RootLayout({
children
children,
}: Readonly<{
children: React.ReactNode;
}>) {
@@ -20,6 +20,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
<Toaster richColors position="top-center" />
</body>
</html>
);
+111 -69
View File
@@ -1,94 +1,136 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import { Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useResumeStore } from "@/store/useResumeStore";
import { convertImagesToBase64 } from "@/utils";
import { Button } from "@/components/ui/button";
const getOptimizedStyles = () => {
const styleCache = new Map();
const startTime = performance.now();
const styles = Array.from(document.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.filter((rule) => {
const ruleText = rule.cssText;
if (styleCache.has(ruleText)) return false;
styleCache.set(ruleText, true);
if (rule instanceof CSSFontFaceRule) return false;
if (ruleText.includes("font-family")) return false;
if (ruleText.includes("@keyframes")) return false;
if (ruleText.includes("animation")) return false;
if (ruleText.includes("transition")) return false;
return true;
})
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
console.warn("Style processing error:", e);
return "";
}
})
.join("\n");
console.log(`Style processing took ${performance.now() - startTime}ms`);
return styles;
};
const optimizeImages = async (element: HTMLElement) => {
const startTime = performance.now();
const images = element.getElementsByTagName("img");
const imagePromises = Array.from(images)
.filter((img) => !img.src.startsWith("data:"))
.map(async (img) => {
try {
const response = await fetch(img.src);
const blob = await response.blob();
return new Promise<void>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result as string;
resolve();
};
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("Image conversion error:", error);
return Promise.resolve();
}
});
await Promise.all(imagePromises);
console.log(`Image processing took ${performance.now() - startTime}ms`);
};
const PdfExport = () => {
const [isExporting, setIsExporting] = useState(false);
const { activeResume } = useResumeStore();
const { globalSettings = {} } = activeResume || {};
const { globalSettings = {}, title } = activeResume || {};
const handleExport = async () => {
const exportStartTime = performance.now();
setIsExporting(true);
const pdfElement = document.querySelector<HTMLElement>("#resume-preview");
if (!pdfElement) return;
const pageBreakLines =
pdfElement.querySelectorAll<HTMLElement>(".page-break-line");
pageBreakLines.forEach((line: HTMLElement) => {
line.style.display = "none";
});
const pdfContent = await convertImagesToBase64(pdfElement);
try {
const pdfElement = document.querySelector<HTMLElement>("#resume-preview");
if (!pdfElement) {
throw new Error("PDF element not found");
}
// 不过滤字体
// let styles = Array.from(document.styleSheets)
// .map((sheet) => {
// try {
// return Array.from(sheet.cssRules)
// .map((rule) => rule.cssText)
// .join("\n");
// } catch (e) {
// return "";
// }
// })
// .join("\n");
const clonedElement = pdfElement.cloneNode(true) as HTMLElement;
// 过滤字体
let styles = Array.from(document.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.filter((rule) => {
// 过滤掉 @font-face 规则
if (rule instanceof CSSFontFaceRule) return false;
// 过滤掉包含 font 相关属性的规则
if (rule.cssText.includes("font-family")) return false;
return true;
})
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
return "";
}
})
.join("\n");
const pageBreakLines =
clonedElement.querySelectorAll<HTMLElement>(".page-break-line");
pageBreakLines.forEach((line) => {
line.style.display = "none";
});
const response = await fetch("/generate-pdf", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: pdfContent,
styles: styles,
margin: globalSettings.pagePadding,
}),
});
const [styles] = await Promise.all([
getOptimizedStyles(),
optimizeImages(clonedElement),
]);
if (!response.ok) {
throw new Error("Failed to generate PDF");
const response = await fetch("/generate-pdf", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: clonedElement.outerHTML,
styles,
}),
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${title}.pdf`;
link.click();
window.URL.revokeObjectURL(url);
console.log(`Total export took ${performance.now() - exportStartTime}ms`);
toast.success("PDF 导出成功!");
} catch (error) {
console.error("Export error:", error);
toast.error("PDF 导出失败,请重试");
} finally {
setIsExporting(false);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "document.pdf";
link.click();
window.URL.revokeObjectURL(url);
setIsExporting(false);
pageBreakLines.forEach((line) => {
line.style.display = "block";
});
};
return (
<div>
<Button
className=" px-4 py-2 rounded-lg text-sm font-medium flex items-center
className="px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2
disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleExport}
disabled={isExporting}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
+14
View File
@@ -224,6 +224,9 @@ importers:
react-resizable-panels:
specifier: ^2.0.20
version: 2.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
sonner:
specifier: ^1.7.1
version: 1.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: ^2.3.0
version: 2.3.0
@@ -4627,6 +4630,12 @@ packages:
resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@1.7.1:
resolution: {integrity: sha512-b6LHBfH32SoVasRFECrdY8p8s7hXPDn3OHUFbZZbiB1ctLS9Gdh6rpX2dVrpQA0kiL5jcRzDDldwwLkSKk3+QQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@@ -10296,6 +10305,11 @@ snapshots:
ip-address: 9.0.5
smart-buffer: 4.2.0
sonner@1.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
source-map-js@1.2.0: {}
source-map-support@0.5.13: