# PDF export

> Fabric.js export PDF files with real, selectable text: A4, Letter or canvas-sized pages, margins, your own fonts and several pages in one file.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/pdf-export
Last updated: 2026-10-01

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.

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

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

## Install the PDF libraries

PDF export uses [jsPDF](https://github.com/parallax/jsPDF) and [svg2pdf.js](https://github.com/yWorks/svg2pdf.js). They are optional, so the rest of the package stays free of dependencies, and they load only when a PDF is made.

```bash
npm install jspdf svg2pdf.js
```

## Export a page

```ts
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

| Option        | Values                                                                           | Default     |
| ------------- | -------------------------------------------------------------------------------- | ----------- |
| `page`        | `'A3'`, `'A4'`, `'A5'`, `'Letter'`, `'Legal'`, `'Tabloid'`, `'canvas'` or `[w, h]` | `'canvas'`  |
| `orientation` | `'portrait'`, `'landscape'` or `'auto'`                                          | `'auto'`    |
| `margin`      | Points, 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:

```ts
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

```ts
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:

```ts
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](https://fabricjs-document-engine.jscrate.dev/docs/api/pdf-api). For images and SVG, see [export](https://fabricjs-document-engine.jscrate.dev/docs/guides/export) and [SVG export](https://fabricjs-document-engine.jscrate.dev/docs/guides/svg-export).
