Fabric.js Document Engine

Search documentation

Find a page or section

中文

Rendering many documents

View as Markdown

Thumbnails and exports for hundreds of documents in one tab, with flat memory.

Making thumbnails for hundreds of saved designs usually means Fabric.js multiple canvases, one per design. The tab runs out of memory long before the end, because browsers give back a canvas's memory late, and a canvas dispose call in Fabric.js does not free it at all. renderDocuments uses a few reused canvases instead and frees everything after each document.

Loading the live demo. Its code is below.

batch-render-demo.tsx
"use client";
 
import { type FabricDocument, renderDocuments } from "fabricjs-document-engine";
import { CircleStopIcon, ImagesIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
 
import { COLORS, DemoFrame, Status, Toolbar, ToolButton } from "./demo-ui";
import { PAGE } from "./use-fabric-canvas";
 
const COUNT = 24;
 
/** A saved design, as a database would return it. */
function savedDesign(index: number): FabricDocument {
  const color = (offset: number) => COLORS[(index + offset) % COLORS.length];
  return {
    schemaVersion: 1,
    id: `design-${index + 1}`,
    createdAt: "",
    updatedAt: "",
    canvas: { width: PAGE.width, height: PAGE.height, background: "#ffffff" },
    objects: [
      {
        type: "Rect",
        originX: "left",
        originY: "top",
        left: 40,
        top: 40,
        width: 400,
        height: 220,
        rx: 24,
        ry: 24,
        fill: color(0),
      },
      {
        type: "Circle",
        originX: "left",
        originY: "top",
        left: 70 + (index % 5) * 50,
        top: 80,
        radius: 60,
        fill: color(2),
      },
      {
        type: "Textbox",
        originX: "left",
        originY: "top",
        left: 250,
        top: 120,
        width: 170,
        text: `#${index + 1}`,
        fontSize: 64,
        fontFamily: "sans-serif",
        fill: "#0d0d0d",
      },
    ],
    metadata: {},
  };
}
 
export default function BatchRenderDemo() {
  const t = useTranslations("demos");
  const [thumbnails, setThumbnails] = useState<
    Array<{ id: string; url: string }>
  >([]);
  const [done, setDone] = useState(0);
  const [running, setRunning] = useState(false);
  const [message, setMessage] = useState("");
  const controller = useRef<AbortController | null>(null);
  const urls = useRef<string[]>([]);
 
  // Thumbnails are object URLs; free them when the demo goes away.
  useEffect(
    () => () => urls.current.forEach((url) => URL.revokeObjectURL(url)),
    []
  );
 
  async function render() {
    urls.current.forEach((url) => URL.revokeObjectURL(url));
    urls.current = [];
    setThumbnails([]);
    setDone(0);
    setRunning(true);
    const current = new AbortController();
    controller.current = current;
    const started = performance.now();
    const designs = Array.from({ length: COUNT }, (_, index) =>
      savedDesign(index)
    );
    try {
      for await (const { documentId, result } of renderDocuments(designs, {
        format: "png",
        scale: 0.25,
        concurrency: 2,
        signal: current.signal,
        onProgress: ({ done: finished }) => setDone(finished),
      })) {
        if (!result) continue;
        const url = URL.createObjectURL(result.blob);
        urls.current.push(url);
        setThumbnails((previous) => [
          ...previous,
          { id: documentId ?? url, url },
        ]);
      }
      setMessage(
        t("batch.done", {
          count: COUNT,
          ms: Math.round(performance.now() - started),
        })
      );
    } catch {
      setMessage(t("batch.cancelled"));
    } finally {
      controller.current = null;
      setRunning(false);
    }
  }
 
  return (
    <DemoFrame>
      <ul
        aria-label={t("batch.list")}
        className="grid aspect-480/300 grid-cols-6 content-start gap-1.5 overflow-hidden rounded-lg border bg-muted/40 p-2"
      >
        {thumbnails.map(({ id, url }) => (
          <li key={id} className="overflow-hidden rounded-sm border bg-white">
            {/* eslint-disable-next-line @next/next/no-img-element -- a rendered PNG from the batch */}
            <img src={url} alt={id} className="block aspect-480/300 w-full" />
          </li>
        ))}
      </ul>
 
      <Toolbar>
        <ToolButton
          icon={ImagesIcon}
          label={t("batch.render", { count: COUNT })}
          primary
          disabled={running}
          onClick={() => void render()}
        />
        <ToolButton
          icon={CircleStopIcon}
          label={t("batch.cancel")}
          disabled={!running}
          onClick={() => controller.current?.abort()}
        />
      </Toolbar>
 
      <Status tone={running ? "busy" : message ? "ok" : "idle"}>
        {running
          ? t("batch.progress", { done, total: COUNT })
          : message || t("batch.hint")}
      </Status>
    </DemoFrame>
  );
}

Render thumbnails

import { renderDocuments } from "fabricjs-document-engine";
 
for await (const { documentId, result, error } of renderDocuments(savedDocuments, {
  format: "png",
  scale: 0.25,
  concurrency: 2,
})) {
  if (result) await uploadThumbnail(documentId, result.blob);
  else console.warn(documentId, error?.message);
}
  • Results arrive as each document finishes, so each one can be uploaded and dropped instead of being held in memory.
  • A broken document reports its own error, and the rest still render.
  • Every ExportOptions field works, so the same loop can make a canvas to image PNG, a JPEG, an SVG or a JSON export.

Documents from a database

documents can be any iterable, or an async iterable such as pages of a query, so the whole set never sits in memory at once:

async function* allDesigns() {
  for (let page = 0; ; page += 1) {
    const rows = await db.designs.findMany({ skip: page * 50, take: 50 });
    if (rows.length === 0) return;
    yield* rows.map((row) => row.document);
  }
}
 
for await (const rendered of renderDocuments(allDesigns(), { format: "png", scale: 0.5 })) {
  // store rendered.result
}

Memory, cancelling and progress

OptionPurposeDefault
concurrencyHow many documents render at once, each on its canvas2
onProgressCalled with { done, total } after each documentnone
signalStops the batch with EXPORT_ABORTEDnone
customObjects, assets, limitsAs for createDocumentEnginenone
  • Fabric.js multiple canvases are avoided: only concurrency canvases exist, whatever the number of documents.
  • After each document, its objects, their cache canvases and the export canvas are released at once.
  • Stopping early, with break or the signal, frees every canvas too.

Rendering needs a browser. For PDF files with one page per document, pass the documents to PDF export. The function is listed with the helpers.