/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
纯前端导出 PDF/PNG:html2canvas 与 jspdf 的排版避坑

在做手账卡片生成器、情绪日记导出或轻量简历工具时,“将网页中的某个 DOM 节点一键保存为高清 PNG 图片或多页 PDF”是最高频的核心功能。
很多前端开发者在实现这个需求时,第一反应是安装 html2canvas 和 jspdf。但在实际落地到生产环境时,会遇到一连串令人抓狂的排版问题:
今天我把在手账工具中经过上万次导出验证的纯前端高清渲染避坑方案完整整理出来。
+——————————————————————–+
| 纯前端 DOM 转图片/PDF 高清渲染流水线 |
+——————————————————————–+
| [目标 DOM 节点 (带有手账纸张纹理与自定义字体)] |
| | |
| v 1. 字体预加载锁 (document.fonts.ready 确保字形渲染完毕) |
| v 2. 外链图片转 Base64 代理 (规避 Canvas 跨域污染 Taint 错误)|
| v 3. 动态克隆节点与色彩降级 (oklch 转 RGBA 保底) |
| | |
| [html2canvas-pro / modern-screenshot 引擎 (scale: 2 ~ 3)] |
| | |
| ├── 导出 PNG -> canvas.toBlob() -> 纯客户端下载 |
| └── 导出 PDF -> jsPDF 分页与物理尺寸 (A4/B5) 计算 |
+——————————————————————–+
1. 坑点一:字体未加载完成即触发截图导致字形回退
很多时候用户点击导出按钮,前端直接调用截图函数,此时浏览器的自定义手写字体(如 Caveat 或自定义中文字体)可能还在异步下载或解析中,html2canvas 无法捕获字形,直接按默认系统字体渲染,卡片质感全毁。
标准解法:在截图前使用原生 document.fonts.ready 进行字形就绪拦截:
export async function waitForFontsLoaded(): Promise<void> {
if ("fonts" in document) {
await document.fonts.ready;
}
}
2. 坑点二:跨域图片污染 Canvas 导致无法导出(Tainted Canvas)
如果手账卡片里包含来自第三方图床的头像或贴纸,直接截图会触发浏览器的 CORS 安全拦截,导致 canvas.toDataURL() 抛出 SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement'。
标准解法:在绘制前将所有外链图片转为内存中的 Base64 Data-URI:
export async function convertImgToBase64(url: string): Promise<string> {
const response = await fetch(url);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
3. 高清 2x/3x 截图与 PDF 导出完整代码
封装一个类型安全的导出工具函数:
import html2canvas from "html2canvas";
import { jsPDF } from "jspdf";
interface ExportCardOptions {
element: HTMLElement;
fileName: string;
format?: "png" | "pdf";
}
export async function exportHandbookCard({
element,
fileName,
format = "png",
}: ExportCardOptions) {
// 1. 等待字体与图片全部就绪
await waitForFontsLoaded();
// 2. 配置高倍率缩放以适配 Retina 视网膜清晰度
const canvas = await html2canvas(element, {
scale: 2, // 2倍物理像素,保证打印与放大不模糊
useCORS: true,
backgroundColor: null, // 保留透明圆角边缘
logging: false,
});
if (format === "png") {
// 3. 纯前端零网络下载 PNG
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName}.png`;
link.click();
URL.revokeObjectURL(url);
}, "image/png");
} else {
// 4. 导出为标准纸张尺寸 PDF
const imgData = canvas.toDataURL("image/png");
const pdf = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a5", // A5 手账尺寸
});
const imgProps = pdf.getImageProperties(imgData);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;
pdf.addImage(imgData, "PNG", 0, 0, pdfWidth, pdfHeight);
pdf.save(`${fileName}.pdf`);
}
}
把所有的渲染与转换全部留在用户浏览器端完成,不仅帮你的服务器节省了大量的 CPU 图形计算开销,还能给用户带来即点即得的导出体验。



