Fabric.js Document Engine

搜索文档

查找页面或章节

EN

PDF 导出

查看 Markdown

矢量页面加真实文字,只有 PDF 画不出的效果才会变成图片。

常见的 js 导出 PDF 文件做法,是把画布截图塞进 jsPDF。打印出来很模糊,文字也不能搜索和复制。用这个包做 Fabric.js 导出 PDF,图形是矢量,文字是可以选中的真实文字,只有 PDF 实在画不出来的对象才会变成图片。

正在加载在线示例,代码在下方。

pdf-export-demo.tsx
"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 导出。