Fabric.js Document Engine

Search documentation

Find a page or section

中文

Vector pages with real text, and a picture only where PDF cannot draw an effect.

The usual Fabric.js to PDF recipe pastes a screenshot of the canvas into jsPDF. The page looks blurry when printed, and nobody can search or copy its text. A Fabric.js export PDF with this package draws shapes as vectors and text as real, selectable text, and only turns an object into a picture when PDF has no way to draw it.

Loading the live demo. Its code is below.

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>
  );
}

Install the PDF libraries

PDF export uses jsPDF and svg2pdf.js. They are optional, so the rest of the package stays free of dependencies, and they load only when a PDF is made.

npm install jspdf svg2pdf.js

Export a page

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");

The current zoom and pan do not matter, and the export never changes the canvas or the undo history.

Page size, margins and fit

OptionValuesDefault
page'A3', 'A4', 'A5', 'Letter', 'Legal', 'Tabloid', 'canvas' or [w, h]'canvas'
orientation'portrait', 'landscape' or 'auto''auto'
marginPoints, as one number or { top, right, bottom, left }0
fit'contain', 'cover' or 'none''contain'
  • Sizes are in PDF points: 72 points make an inch, so margin: 36 is half an inch.
  • 'canvas' makes a page size that matches the canvas at 96 pixels to the inch.
  • 'auto' turns a named page sideways when the canvas is wider than it is tall.
  • 'cover' fills the box inside the margins and clips the rest; 'none' prints at real size from the top-left corner.

Vector, hybrid and raster

  • hybrid (the default) draws everything as vectors, except what PDF vectors cannot show: shadows, blend modes, gradient outlines, outlines that keep their width while scaled, and text in a font with no file. Each of those becomes a 300 dpi picture of that object alone, in its place.
  • vector never makes pictures. Effects PDF cannot draw are left out, and warnings says which objects look different.
  • raster draws each page as one picture at dpi. It needs only jspdf, but the text cannot be selected.

Fonts and selectable text

Text stays selectable text only when the PDF has its font. Pass a TrueType (.ttf) file for each family the canvas uses, one entry per weight and style:

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 and the generic families map to the fonts every PDF reader has, so they need no file.
  • Text in any other family with no file is drawn as a picture in hybrid mode. Pass missingFonts: "substitute" to keep it as text in the closest built-in font instead.
  • The built-in fonts have Latin-1 letters only. Chinese, Arabic and other scripts always need a font file that has those letters.
  • WOFF and WOFF2 files are refused with PDF_FAILED. Most font sites offer a .ttf next to the web formats.

Warnings

const { warnings } = await exportPdf(engine);
for (const warning of warnings) {
  console.log(warning.code, warning.page, warning.objectIds, warning.message);
}
  • PDF_RASTERIZED: an object was drawn as a picture in hybrid mode, and the message says why.
  • PDF_UNSUPPORTED: in vector mode, an object may look different.
  • PDF_FONT_SUBSTITUTED: text used a built-in font, or another file of its family.
  • IMAGE_NOT_EMBEDDED: an image could not be read, usually because of CORS.

Several pages

Pass an array to get one page per source. A source is an engine, a Fabric canvas or a saved document:

const saved = await Promise.all(ids.map((id) => storage.loadDocument(id)));
const { blob, pageCount } = await exportPdf(saved, { page: "Letter", margin: 36 });

Saved documents are drawn on an off-screen canvas that is freed after each page. Every option of a Fabric.js export PDF call is listed in the PDF API. For images and SVG, see export and SVG export.