# 导出

> Fabric.js 导出图片（PNG、JPEG、WebP），也能导出 SVG 或 JSON。可选区域和倍数，在导出失败前找出坏图片。

Source: https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/export
Last updated: 2026-09-28

一次调用就能处理所有 Fabric.js 导出图片的格式，还有 SVG 和 JSON。区域、倍数和背景都由你选。当前的缩放和平移不影响结果，因为导出总是使用文档坐标。

```tsx
"use client";

import { Textbox } from "fabric";
import { downloadExport, type ExportFormat } from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { BracesIcon, FileCodeIcon, ImageIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";

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

const FORMATS = [
  { format: "png", icon: ImageIcon },
  { format: "svg", icon: FileCodeIcon },
  { format: "json", icon: BracesIcon },
] as const;

export default function ExportDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const [message, setMessage] = useState("");
  const title = t("export.title");

  // A small drawing to export. Text is measured once its font has loaded.
  useEffect(() => {
    if (!engine) return;
    document.fonts.load("600 32px Yellix").finally(() => {
      engine.canvas.add(
        ...seedShapes(),
        new Textbox(title, {
          originX: "left",
          originY: "top",
          left: 48,
          top: 108,
          width: 176,
          fontFamily: "Yellix",
          fontSize: 26,
          fontWeight: 600,
          textAlign: "center",
          fill: "#0d0d0d",
        })
      );
      engine.clearHistory();
    });
  }, [engine, title]);

  async function download(format: ExportFormat) {
    if (!engine) return;
    try {
      const result = await engine.export({ format, scale: 2 });
      downloadExport(result, `drawing.${format}`);
      setMessage(
        t("export.done", {
          width: result.width,
          height: result.height,
          format: format.toUpperCase(),
        })
      );
    } catch (error) {
      setMessage(t("export.failed", { message: String(error) }));
    }
  }

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

      <Toolbar>
        {FORMATS.map(({ format, icon }) => (
          <ToolButton
            key={format}
            icon={icon}
            label={format.toUpperCase()}
            disabled={!engine}
            onClick={() => void download(format)}
          />
        ))}
      </Toolbar>

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

## 导出并下载

```ts
import { downloadExport } from "fabricjs-document-engine";

const result = await engine.export({ format: "png", scale: 2 });
downloadExport(result, "poster.png");
```

`result` 是 `{ format, mimeType, blob, width, height, warnings }`。你可以上传 `blob`、放进 `<img>` 显示，或用 `downloadExport` 下载。

## 配置项

| 配置项       | 取值                                                                     | 默认值     |
| ------------ | ------------------------------------------------------------------------ | ---------- |
| `format`     | `'png'`、`'jpeg'`、`'webp'`、`'svg'` 或 `'json'`                         | 必填       |
| `scale`      | 输出尺寸倍数，比如 Retina 屏用 `2`                                       | `1`        |
| `quality`    | 0 到 1，用于 JPEG 和 WebP                                                | `0.92`     |
| `area`       | `'canvas'`、`'content'`、`'selection'` 或 `{ left, top, width, height }` | `'canvas'` |
| `padding`    | `content` 或 `selection` 周围的留白                                      | `0`        |
| `background` | `'keep'`、`'transparent'` 或任意 CSS 颜色                                | `'keep'`   |
| `signal`     | 用于取消的 `AbortSignal`                                                 | —          |

- `'content'` 会裁剪到对象范围，做缩略图很方便。
- JPEG 没有透明度，所以空白或透明的背景会变成白色，而不是黑色。
- 导出不会改变画布、撤销历史或未保存状态。

## 导出 SVG

```ts
const svg = await engine.export({
  format: "svg",
  area: "content",
  padding: 20,
});
```

导出 SVG 时，引擎会转义所有文字，所以文本框里的 `<script>` 仍然只是文字。

## 导出 JSON

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

导出 JSON 得到的，和保存时生成的可移植文档完全相同。设置了 `assets.upload` 时，只存在于当前标签页的图片会先上传，所以文件在任何设备上都能打开。

## 导出前检查

画布被污染（tainted canvas）是 Fabric.js 导出图片失败的经典原因。渲染之前，引擎会检查每个对象：

- `MISSING_IMAGE`：图片没有加载成功。
- `CROSS_ORIGIN_IMAGE`：来自其他网站、没开 CORS 的图片会让浏览器拦截 PNG、JPEG 或 WebP 导出。SVG 和 JSON 不受影响。
- `MISSING_FONT`：字体不可用，且开启了 `assets.requireFonts`。

发现问题时，`export` 会以 `EXPORT_BLOCKED` 拒绝，`error.problems` 列出每个 `{ code, message, url, objectIds }`。先运行同样的检查，就能在菜单里提示用户：

```ts
const check = await engine.preflightExport({ format: "png" });
if (!check.ok) showProblems(check.problems);
```

每种问题的修复方法见[图片和字体](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/assets-and-fonts)。这个包不提供 PDF 导出；可以先用 Fabric.js 导出图片，再放进你选的 PDF 库里。
