常见的 js 导出 PDF 文件做法,是把画布截图塞进 jsPDF。打印出来很模糊,文字也不能搜索和复制。用这个包做 Fabric.js 导出 PDF,图形是矢量,文字是可以选中的真实文字,只有 PDF 实在画不出来的对象才会变成图片。
正在加载在线示例,代码在下方。
"use client";
import { Rect, Shadow, Textbox } from "fabric";
import { downloadExport } from "fabricjs-document-engine";
import { exportPdf, type PdfMode } from "fabricjs-document-engine/pdf";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { FileTextIcon, ImageIcon, LayersIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import {
COLORS,
DemoCanvas,
DemoFrame,
seedShapes,
Status,
Toolbar,
ToolButton,
} from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";
// PDF export needs a TrueType file for text to stay selectable.
const FONT = { family: "Roboto", url: "/fonts/Roboto-Medium.ttf" };
const MODES: { mode: PdfMode; icon: typeof FileTextIcon }[] = [
{ mode: "hybrid", icon: LayersIcon },
{ mode: "vector", icon: FileTextIcon },
{ mode: "raster", icon: ImageIcon },
];
export default function PdfExportDemo() {
const t = useTranslations("demos");
const { elementRef, canvas } = useFabricCanvas();
const engine = useDocumentEngine(canvas);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const title = t("pdf.title");
useEffect(() => {
if (!engine) return;
const face = new FontFace(FONT.family, `url(${FONT.url})`, {
weight: "500",
});
face
.load()
.then((loaded) => document.fonts.add(loaded))
.finally(() => {
engine.canvas.add(
...seedShapes(),
// A shadow is one thing PDF vectors cannot draw.
new Rect({
originX: "left",
originY: "top",
left: 300,
top: 150,
width: 120,
height: 30,
rx: 15,
ry: 15,
fill: COLORS[2],
shadow: new Shadow({
color: "rgba(0,0,0,0.35)",
blur: 10,
offsetY: 4,
}),
}),
new Textbox(title, {
originX: "left",
originY: "top",
left: 48,
top: 104,
width: 176,
fontFamily: FONT.family,
fontSize: 26,
textAlign: "center",
fill: "#0d0d0d",
})
);
engine.clearHistory();
});
}, [engine, title]);
async function download(mode: PdfMode) {
if (!engine) return;
setBusy(true);
try {
const { blob, warnings } = await exportPdf(engine, {
page: "A4",
margin: 36,
mode,
fonts: [{ family: FONT.family, source: FONT.url }],
metadata: { title },
});
downloadExport({ blob, format: "pdf" }, `drawing-${mode}.pdf`);
setMessage(
t("pdf.done", {
mode,
pictures: warnings.filter(
(warning) => warning.code === "PDF_RASTERIZED"
).length,
})
);
} catch (error) {
setMessage(t("pdf.failed", { message: String(error) }));
} finally {
setBusy(false);
}
}
return (
<DemoFrame>
<DemoCanvas elementRef={elementRef} label={t("canvas")} />
<Toolbar>
{MODES.map(({ mode, icon }) => (
<ToolButton
key={mode}
icon={icon}
label={t(`pdf.modes.${mode}`)}
primary={mode === "hybrid"}
disabled={!engine || busy}
onClick={() => void download(mode)}
/>
))}
</Toolbar>
<Status tone={busy ? "busy" : message ? "ok" : "idle"}>
{busy ? t("pdf.busy") : message || t("pdf.hint")}
</Status>
</DemoFrame>
);
}安装 PDF 库
PDF 导出用到 jsPDF 和 svg2pdf.js。它们是可选依赖,所以包的其他部分仍然没有依赖;只有生成 PDF 时才会加载。
npm install jspdf svg2pdf.js导出一页
import { downloadExport } from "fabricjs-document-engine";
import { exportPdf } from "fabricjs-document-engine/pdf";
const { blob, warnings } = await exportPdf(engine, {
page: "A4",
margin: 36,
fonts: [{ family: "Inter", source: "/fonts/Inter-Regular.ttf" }],
metadata: { title: "Spring poster" },
});
downloadExport({ blob, format: "pdf" }, "poster.pdf");当前的缩放和平移不影响结果,导出也不会改变画布或撤销历史。
页面大小、页边距和缩放
| 配置项 | 取值 | 默认值 |
|---|---|---|
page | 'A3'、'A4'、'A5'、'Letter'、'Legal'、'Tabloid'、'canvas' 或 [w, h] | 'canvas' |
orientation | 'portrait'、'landscape' 或 'auto' | 'auto' |
margin | 单位是点,可以是一个数字或 { top, right, bottom, left } | 0 |
fit | 'contain'、'cover' 或 'none' | 'contain' |
- 尺寸单位是 PDF 点:72 点等于 1 英寸,所以
margin: 36是半英寸。 'canvas'让页面和画布一样大,按每英寸 96 像素计算。- 画布宽大于高时,
'auto'会把命名尺寸的页面横过来。 'cover'填满页边距内的区域并裁掉多余部分;'none'从左上角按实际大小打印。
矢量、混合和整页图片
hybrid(默认)把所有内容画成矢量,只有 PDF 矢量表现不了的部分除外:阴影、混合模式、渐变描边、缩放时保持宽度的描边,以及没有字体文件的文字。这些对象会在原位置单独画成 300 dpi 的图片。vector从不生成图片。PDF 画不出的效果会被略去,warnings会指出哪些对象看起来不一样。raster把每页画成一张dpi分辨率的图片。只依赖jspdf,但文字无法选中。
字体和可选中的文字
只有 PDF 里有对应字体时,文字才能保持为可选中的文字。为画布用到的每个字体族传入 TrueType(.ttf)文件,每种字重和样式一项:
await exportPdf(engine, {
fonts: [
{ family: "Inter", source: "/fonts/Inter-Regular.ttf" },
{ family: "Inter", source: "/fonts/Inter-Bold.ttf", weight: "bold" },
{ family: "Inter", source: "/fonts/Inter-Italic.ttf", style: "italic" },
],
});- Arial、Helvetica、Times、Courier 和通用字体族会对应到所有 PDF 阅读器都有的字体,不需要文件。
- 其他没有文件的字体,在混合模式下会画成图片。传入
missingFonts: "substitute"则改用最接近的内置字体,仍保持为文字。 - 内置字体只有 Latin-1 字母。中文、阿拉伯文等文字一定要提供包含这些字的字体文件。
- WOFF 和 WOFF2 文件会以
PDF_FAILED被拒绝。大多数字体网站在网页格式之外都提供 .ttf。
警告
const { warnings } = await exportPdf(engine);
for (const warning of warnings) {
console.log(warning.code, warning.page, warning.objectIds, warning.message);
}PDF_RASTERIZED:混合模式下某个对象被画成了图片,消息里会说明原因。PDF_UNSUPPORTED:矢量模式下某个对象可能看起来不一样。PDF_FONT_SUBSTITUTED:文字用了内置字体,或同一字体族的另一个文件。IMAGE_NOT_EMBEDDED:某张图片无法读取,通常是 CORS 的原因。
多页
传入数组,每个来源生成一页。来源可以是引擎、Fabric 画布或已保存的文档:
const saved = await Promise.all(ids.map((id) => storage.loadDocument(id)));
const { blob, pageCount } = await exportPdf(saved, { page: "Letter", margin: 36 });已保存的文档会画在离屏画布上,每页完成后释放。Fabric.js 导出 PDF 的所有选项见 PDF API。图片和 SVG 请看导出和 SVG 导出。