# SVG 导出

> fabricjs导出svg 和画布一致：曲线文字保持位置、背景和下划线，图片和字体也能嵌入文件。

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

直接用 fabricjs导出svg 有两个问题。沿路径排列的文字位置不对；文件通过 URL 链接图片，链接一失效就只剩空框。引擎的 SVG 导出解决了这两点，其他对象仍然和 Fabric 写出来的完全一样。和其他 canvas导出svg 的做法相比，它不需要额外处理。

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

## 曲线文字

Fabric.js 的 SVG 里没有 `<textPath>`，svg 文字路径是用一个个旋转的字母写出来的。这种输出会忽略 `pathAlign`，把抬高的字母移向错误的方向，还把文字背景和下划线画成直的。文字里有空格时，Fabric 6 和 7 甚至会写出无效的 XML，浏览器和 Illustrator 都打不开。

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

现在每次 SVG 导出都会把曲线文字的每个字母写在画布绘制它的位置，背景和装饰线也在同一个位置：

- 沿路径的 `pathAlign`、`pathSide`、`pathStartOffset` 和 `textAlign`
- 单个字母的样式、抬高和降低的字母（`deltaY`）、描边和 `paintFirst`
- 文字背景、下划线、上划线和删除线
- 可见时，路径本身

每个字母都是带变换的普通 `<text>`，所有 SVG 阅读器都能理解，文字也仍然可以编辑。传入 `svg: { textOnPath: "fabric" }` 可以保留 Fabric 自己的输出。

## 嵌入图片和字体

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

- `embedImages: true` 会嵌入图片，把每张图片作为数据放进文件，包括图案填充和编组里的图片。来自其他网站、没有 CORS 的图片会保留为链接，并给出带对象 id 的 `IMAGE_NOT_EMBEDDED` 警告。
- 图片无法嵌入时，`embedImages: "require"` 会以 `EXPORT_BLOCKED` 阻止导出。
- `maxEmbeddedImageBytes` 会略过超过限制的图片（默认 25 MB）。
- `embedFonts` 把字体族对应到一个文件：URL 或文件字节。设置在单个字母上的字体也会被嵌入，而 Fabric 自己的 `@font-face` 输出会漏掉它们，导致换一台电脑打开时字体变形。

## 按需求选择配置项

每次 fabricjs导出svg 都可以用下面的配置项，按文件的去处来选：

| 需求                                     | 配置项                          |
| ---------------------------------------- | ------------------------------- |
| 和画布看起来一样的 SVG                   | 默认值                          |
| 给 Illustrator 或其他设备用的文件        | `embedImages: true`             |
| 在任何电脑上都显示品牌字体               | `embedFonts`                    |
| 用于对比的 Fabric 原始输出               | `textOnPath: "fabric"`          |

编组里有沿路径排列的文字时，给编组设置 `objectCaching: false`。开启缓存时，Fabric 会在画布上裁掉高出路径的字母，而 SVG 会完整显示它们。更多导出格式见[导出](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/export)，[PDF 导出](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/pdf-export)也用了同样的曲线文字修正。
