# SVG 导入

> Fabric.js 导入 SVG 文件时按 viewBox 放置，即使有元素在外面也不会错位，导入前会先移除脚本。

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

在 Fabric.js 里加载 SVG，常见的做法是先用 `loadSVGFromString`，再用 `util.groupSVGElements`，它会按绘制的内容确定编组大小。SVG 的 viewBox 之外的元素，或者隐藏的元素，会让整幅图移动并改变大小。通过引擎做 Fabric.js 导入 SVG，则会保留文件自己的画框。

```tsx
"use client";

import { type FabricObject, loadSVGFromString, util } from "fabric";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { FileUpIcon, ImportIcon, ScissorsIcon, ShapesIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";

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

/**
 * A badge drawn in a 240 × 150 viewBox, plus one shape far outside it and
 * one hidden shape. Grouping by content lets those two move the badge.
 */
const SAMPLE = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="150" viewBox="0 0 240 150">
  <rect x="10" y="10" width="220" height="130" rx="18" fill="#ffd666"/>
  <circle cx="70" cy="75" r="36" fill="#f2836f"/>
  <rect x="124" y="52" width="86" height="16" rx="8" fill="#0d0d0d"/>
  <rect x="124" y="82" width="60" height="16" rx="8" fill="#6f00ff"/>
  <circle cx="900" cy="600" r="40" fill="#5fe6c4"/>
  <rect x="-500" y="-300" width="40" height="40" fill="#7ad9ec" style="display:none"/>
</svg>`;

export default function SvgImportDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const fileRef = useRef<HTMLInputElement>(null);
  const [message, setMessage] = useState("");

  function clear() {
    if (!engine) return;
    engine.canvas.remove(...engine.canvas.getObjects());
  }

  // The usual way: Fabric's parser, then a group sized to the content.
  async function importWithFabric() {
    if (!engine) return;
    clear();
    const { objects, options } = await loadSVGFromString(SAMPLE);
    const group = util.groupSVGElements(
      objects.filter(Boolean) as FabricObject[],
      options
    );
    group.set({ left: 120, top: 75, originX: "left", originY: "top" });
    engine.canvas.add(group);
    setMessage(t("svgImport.fabric"));
  }

  async function importSvg(svg: string, offscreen: "keep" | "clip") {
    if (!engine) return;
    clear();
    try {
      const { warnings } = await engine.importSvg(svg, {
        left: 120,
        top: 75,
        offscreen,
      });
      setMessage(
        warnings.length > 0
          ? t("svgImport.warnings", { count: warnings.length })
          : t("svgImport.engine")
      );
    } catch (error) {
      setMessage(t("svgImport.failed", { message: String(error) }));
    }
  }

  async function openFile(file: File | undefined) {
    if (!engine || !file) return;
    clear();
    try {
      await engine.importSvg(await file.text(), {
        left: 20,
        top: 20,
        fit: { width: 440, height: 260 },
      });
      setMessage(t("svgImport.file", { name: file.name }));
    } catch (error) {
      setMessage(t("svgImport.failed", { message: String(error) }));
    }
  }

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

      <Toolbar>
        <ToolButton
          icon={ImportIcon}
          label={t("svgImport.withEngine")}
          primary
          disabled={!engine}
          onClick={() => void importSvg(SAMPLE, "keep")}
        />
        <ToolButton
          icon={ShapesIcon}
          label={t("svgImport.withFabric")}
          disabled={!engine}
          onClick={() => void importWithFabric()}
        />
        <ToolButton
          icon={ScissorsIcon}
          label={t("svgImport.clip")}
          disabled={!engine}
          onClick={() => void importSvg(SAMPLE, "clip")}
        />
        <ToolButton
          icon={FileUpIcon}
          label={t("svgImport.open")}
          disabled={!engine}
          onClick={() => fileRef.current?.click()}
        />
        <input
          ref={fileRef}
          type="file"
          accept=".svg,image/svg+xml"
          className="sr-only"
          tabIndex={-1}
          aria-hidden
          onChange={(event) => {
            void openFile(event.target.files?.[0]);
            event.target.value = "";
          }}
        />
      </Toolbar>

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

## 导入 SVG 文件

```ts
const { objects, viewport, warnings } = await engine.importSvg(svgText, {
  left: 40,
  top: 40,
});
```

- 元素落在 SVG 放置它们的位置，已经应用了 `viewBox` 和 `preserveAspectRatio`。
- 结果是一个固定布局、大小等于视口的编组，里面的任何内容都不会让它移动。
- 导入只算一步撤销，每个对象都有 id。
- `viewport` 是 SVG 画框的大小，想按指定尺寸放进画布时很有用。

打开用户选择的 SVG 文件：

```ts
input.addEventListener("change", async () => {
  const file = input.files?.[0];
  if (file) await engine.importSvg(await file.text(), { fit: { width: 400, height: 300 } });
});
```

## 放置和缩放图形

| 配置项      | 取值                                         | 默认值       |
| ----------- | -------------------------------------------- | ------------ |
| `left`, `top` | 视口左上角落在画布上的位置                 | `0`          |
| `fit`       | `{ width, height, mode }`，`mode` 为 `'contain'`、`'cover'` 或 `'fill'` | 无 |
| `offscreen` | `'keep'`、`'drop'` 或 `'clip'`               | `'keep'`     |
| `as`        | `'group'` 或 `'objects'`                     | `'group'`    |
| `viewport`  | `'preserve'` 或 `'content'`                  | `'preserve'` |

- `offscreen: "drop"` 会略去完全在视口之外的元素，`"clip"` 会把视口外的部分裁掉。
- `as: "objects"` 会在相同位置添加分开的对象，适合用户需要拆开编辑的文件。
- `viewport: "content"` 按绘制内容的范围计算，和 Fabric 自己的编组一样。没有尺寸的 SVG 总是使用这种方式。

## 不可信的文件

每次 Fabric.js 导入 SVG 都会在 Fabric 解析之前先清理文件：

- 移除脚本、`onload` 之类的事件处理器、`foreignObject` 和指向其他文件的链接。
- `limits.isAllowedUrl` 拒绝的图片地址会被略去。
- 元素过多或嵌套过深的 SVG 会以 `UNSAFE_DOCUMENT` 被拒绝，限制和文档使用的 `limits` 相同。
- 不是 SVG 的文本会以 `SVG_IMPORT_FAILED` 拒绝。

`warnings` 会列出移除或略去的内容，代码有 `SVG_CONTENT_REMOVED`、`SVG_IMAGE_BLOCKED` 和 `SVG_OFFSCREEN_DROPPED`。要把画布导出回 SVG，请看 [SVG 导出](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/svg-export)。配置项列在 [DocumentEngine 方法](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/document-engine)里，安全限制见[安全](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/security)。
