# SVG export

> Fabric.js export SVG files that match the canvas: curved text keeps its place, background and underline, and images and fonts can be embedded.

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

A plain Fabric.js export SVG call has two weak spots. Text on a path comes out in the wrong place, and the file links to its images by URL, so it shows empty boxes once those links stop working. The engine's SVG export fixes both, and every other object stays exactly as Fabric writes it.

```tsx
"use client";

import { FabricText, Path } from "fabric";
import { downloadExport } from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { DownloadIcon, EyeIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";

import {
  COLORS,
  DemoCanvas,
  DemoFrame,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";

type Output = "vector" | "fabric";

export default function SvgExportDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const [preview, setPreview] = useState<{
    url: string;
    output: Output;
  } | null>(null);
  const [message, setMessage] = useState("");
  const words = t("svg.words");

  // Text that follows a curve, centred on it, with a highlight behind it.
  useEffect(() => {
    if (!engine) return;
    engine.canvas.add(
      new FabricText(words, {
        path: new Path("M 40 230 Q 240 20 440 230", {
          fill: "",
          stroke: "#d4d4d8",
          strokeWidth: 2,
          strokeDashArray: [6, 6],
        }),
        left: PAGE.width / 2,
        top: PAGE.height / 2,
        fontFamily: "sans-serif",
        fontSize: 28,
        fill: "#0d0d0d",
        pathAlign: "center",
        textAlign: "center",
        textBackgroundColor: COLORS[4],
        underline: true,
      })
    );
    engine.clearHistory();
  }, [engine, words]);

  // The preview URL belongs to this component; free it when it changes.
  useEffect(
    () => () => {
      if (preview) URL.revokeObjectURL(preview.url);
    },
    [preview]
  );

  async function render(output: Output, download: boolean) {
    if (!engine) return;
    const result = await engine.export({
      format: "svg",
      svg: { textOnPath: output, embedImages: true },
    });
    if (download) {
      downloadExport(result, "curved-text.svg");
      setMessage(t("svg.downloaded"));
      return;
    }
    setPreview({ url: URL.createObjectURL(result.blob), output });
    setMessage(
      t(output === "vector" ? "svg.previewVector" : "svg.previewFabric")
    );
  }

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />

      {preview && (
        <figure className="overflow-hidden rounded-lg border bg-white">
          {/* eslint-disable-next-line @next/next/no-img-element -- a blob URL of the export itself */}
          <img
            src={preview.url}
            alt={t("svg.previewAlt")}
            className="block w-full"
          />
        </figure>
      )}

      <Toolbar>
        <ToolButton
          icon={DownloadIcon}
          label={t("svg.download")}
          primary
          disabled={!engine}
          onClick={() => void render("vector", true)}
        />
        <ToolButton
          icon={EyeIcon}
          label={t("svg.showVector")}
          disabled={!engine}
          onClick={() => void render("vector", false)}
        />
        <ToolButton
          icon={EyeIcon}
          label={t("svg.showFabric")}
          disabled={!engine}
          onClick={() => void render("fabric", false)}
        />
      </Toolbar>

      <Status tone={message ? "ok" : "idle"}>{message || t("svg.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>
  );
}
```

## Curved text

Fabric.js has no `<textPath>` in its SVG. It writes text on a path as rotated letters, and that output ignores `pathAlign`, moves raised letters the wrong way, and draws text backgrounds and underlines as straight boxes. When the text contains a space, Fabric 6 and 7 even write invalid XML, which browsers and Illustrator refuse to open.

```ts
const result = await engine.export({ format: "svg" });
```

Every SVG export now writes each letter of curved text where the canvas draws it, with its background and decorations in the same place:

- `pathAlign`, `pathSide`, `pathStartOffset` and `textAlign` along the path
- letter styles, raised and lowered letters (`deltaY`), outlines and `paintFirst`
- text backgrounds, underlines, overlines and line-throughs
- the guide path itself, when it is visible

Each letter is plain `<text>` with a transform, which every SVG reader understands, and it stays editable text. Pass `svg: { textOnPath: "fabric" }` to keep Fabric's own output instead.

## Embed images and fonts

```ts
const result = await engine.export({
  format: "svg",
  svg: {
    embedImages: true,
    embedFonts: { "Brand Sans": "/fonts/brand-sans.woff2" },
  },
});
```

- `embedImages: true` puts every image into the file as data, including pattern fills and images in groups. An image from another site without CORS stays a link, with an `IMAGE_NOT_EMBEDDED` warning that names the objects.
- `embedImages: "require"` blocks the export with `EXPORT_BLOCKED` instead, when an image cannot be embedded.
- `maxEmbeddedImageBytes` leaves out images larger than the limit (25 MB by default).
- `embedFonts` maps a font family to a file: a URL or the file's bytes. Fonts set on single letters are embedded too, which Fabric's own `@font-face` output misses.

## When to use which option

Every Fabric.js export SVG call accepts these options, so pick them by where the file is going:

| Need                                     | Option                          |
| ---------------------------------------- | ------------------------------- |
| SVG export that looks like the canvas    | the defaults                    |
| A file for Illustrator or another device | `embedImages: true`             |
| Brand fonts on any computer              | `embedFonts`                    |
| Fabric's own output, for comparison      | `textOnPath: "fabric"`          |

For text on a path inside a group, set `objectCaching: false` on the group. With caching on, Fabric clips letters that rise above the path on the canvas, while the SVG shows them. More export formats are on the [export](https://fabricjs-document-engine.jscrate.dev/docs/guides/export) page, and the [PDF export](https://fabricjs-document-engine.jscrate.dev/docs/guides/pdf-export) uses the same curved text fixes.
