kanban / src /utils /export.ts
Leon4gr45's picture
Deploy magic-resume as Docker Space (port 3000) (part 2)
aa1ee73 verified
Raw
History Blame Contribute Delete
8.15 kB
import { toast } from "sonner";
import { PDF_EXPORT_CONFIG } from "@/config";
import { normalizeFontFamily } from "@/utils/fonts";
import { ResumeData } from "@/types/resume";
import { generateResumeMarkdown, ResumeMarkdownOptions } from "@/utils/markdown";
const INVALID_FILE_NAME_CHAR_REGEX = /[\\/:*?"<>|]/g;
const getSafeFileName = (title?: string) => {
const normalized = (title || "resume")
.trim()
.replace(INVALID_FILE_NAME_CHAR_REGEX, "_")
.replace(/\s+/g, " ");
return normalized || "resume";
};
const downloadBlob = (blob: Blob, fileName: string) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
window.URL.revokeObjectURL(url);
};
const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, fileName);
};
export 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;
const normalizedRuleText = ruleText.toLowerCase();
if (styleCache.has(ruleText)) return false;
styleCache.set(ruleText, true);
if (rule instanceof CSSFontFaceRule) return false;
if (rule instanceof CSSImportRule) return false;
if (normalizedRuleText.includes("fonts.googleapis.com")) return false;
if (normalizedRuleText.includes("fonts.gstatic.com")) 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;
if (ruleText.includes("hover")) 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;
};
export 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`);
};
export interface ExportToPdfOptions {
elementId: string;
title: string;
pagePadding: number;
fontFamily?: string;
onStart?: () => void;
onEnd?: () => void;
successMessage?: string;
errorMessage?: string;
}
interface ExportResumeFileOptions {
resume?: ResumeData | null;
title?: string;
onStart?: () => void;
onEnd?: () => void;
successMessage?: string;
errorMessage?: string;
}
interface ExportResumeMarkdownOptions extends ExportResumeFileOptions {
markdownOptions?: ResumeMarkdownOptions;
}
export const exportResumeAsJson = ({
resume,
title,
onStart,
onEnd,
successMessage,
errorMessage
}: ExportResumeFileOptions) => {
onStart?.();
try {
if (!resume) {
throw new Error("No active resume");
}
const json = JSON.stringify(resume, null, 2);
const fileName = `${getSafeFileName(title || resume.title)}.json`;
downloadTextFile(json, fileName, "application/json;charset=utf-8");
if (successMessage) toast.success(successMessage);
} catch (error) {
console.error("JSON export error:", error);
if (errorMessage) toast.error(errorMessage);
} finally {
onEnd?.();
}
};
export const exportResumeAsMarkdown = ({
resume,
title,
onStart,
onEnd,
successMessage,
errorMessage,
markdownOptions
}: ExportResumeMarkdownOptions) => {
onStart?.();
try {
if (!resume) {
throw new Error("No active resume");
}
const markdown = generateResumeMarkdown(resume, markdownOptions);
const fileName = `${getSafeFileName(title || resume.title)}.md`;
downloadTextFile(markdown, fileName, "text/markdown;charset=utf-8");
if (successMessage) toast.success(successMessage);
} catch (error) {
console.error("Markdown export error:", error);
if (errorMessage) toast.error(errorMessage);
} finally {
onEnd?.();
}
};
export const exportToPdf = async ({
elementId,
title,
pagePadding,
fontFamily,
onStart,
onEnd,
successMessage,
errorMessage
}: ExportToPdfOptions) => {
const exportStartTime = performance.now();
onStart?.();
try {
const pdfElement = document.querySelector<HTMLElement>(`#${elementId}`);
if (!pdfElement) {
throw new Error(`PDF element #${elementId} not found`);
}
const clonedElement = pdfElement.cloneNode(true) as HTMLElement;
const selectedFontFamily = normalizeFontFamily(fontFamily);
const transformValue = clonedElement.style.transform || "";
const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/);
if (scaleMatch) {
const scale = Number(scaleMatch[1]);
if (Number.isFinite(scale) && scale > 0 && scale < 1) {
// 服务端导出前将 transform 缩放转为 zoom,避免分页计算偏差
clonedElement.style.removeProperty("transform");
clonedElement.style.removeProperty("transform-origin");
clonedElement.style.setProperty("width", "100%", "important");
clonedElement.style.setProperty("zoom", String(scale));
}
}
// 采用 PdfExport.tsx 中的逻辑,统一宽度和 padding 处理
clonedElement.style.setProperty("width", "100%", "important");
clonedElement.style.setProperty("padding", "0", "important");
clonedElement.style.setProperty("box-sizing", "border-box");
clonedElement.style.setProperty("font-family", selectedFontFamily, "important");
const pageBreakLines = clonedElement.querySelectorAll<HTMLElement>(".page-break-line");
pageBreakLines.forEach((line) => {
line.style.display = "none";
});
const [capturedStyles] = await Promise.all([
getOptimizedStyles(),
optimizeImages(clonedElement)
]);
// 注入 PdfExport.tsx 中的样式增强
const styles = `
${capturedStyles}
html, body { background: white !important; background-color: white !important; }
html, body, #${elementId} {
background: white !important;
background-color: white !important;
font-family: ${selectedFontFamily} !important;
}
`;
const response = await fetch(PDF_EXPORT_CONFIG.SERVER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content: clonedElement.outerHTML,
styles,
margin: pagePadding
}),
mode: "cors",
signal: AbortSignal.timeout(PDF_EXPORT_CONFIG.TIMEOUT)
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`);
}
const blob = await response.blob();
const fileName = `${getSafeFileName(title)}.pdf`;
downloadBlob(blob, fileName);
if (successMessage) toast.success(successMessage);
console.log(`Total export took ${performance.now() - exportStartTime}ms`);
} catch (error) {
console.error("Export error:", error);
if (errorMessage) toast.error(errorMessage);
} finally {
onEnd?.();
}
};