mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
perf: export pdf
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
Generated
+14
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user