# Exporting

> [llms.txt](https://zriz.dev/llms.txt)

Turn the crop into an image file in the browser, or send the crop value to your server and cut the original there.

The page at https\://zriz.dev/docs/exporting runs this cropper: a 3:2 photo, a track of JPEG, PNG and WebP, and open, reset and download in the corners.

## Save the crop as a file

`exportCrop` comes from `@zriz/core/dom`. `@zriz/react` needs `@zriz/core` as a peer dependency and re-exports the types an editor needs, such as `CropValue`, `Cropper` and `ChangeReason`. The types of `exportCrop` and `loadImage`, such as `ExportOptions`, `ExportResult`, `LoadedImage` and `ZrizErrorCode`, come from `@zriz/core/dom` too. Pure functions and the other core types, such as `parse` and `ExportPlan`, come from `@zriz/core`. [Quick start](https://zriz.dev/docs/quick-start.md) installs it. Without React, add it alone:

```sh
pnpm add @zriz/core
```

Keep it at the version `@zriz/react` asks for. A second copy of `@zriz/core` cannot see the photo, see `no-image` on [errors](https://zriz.dev/docs/errors.md).

Pass a ref to `actionsRef` on `Root`, and pass `cropper.current` to `exportCrop` from a save button. The button stays disabled until `onLoad` fires for this file, because an export before the photo loads rejects with `no-image`:

```tsx
"use client";

import { exportCrop } from "@zriz/core/dom";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import { useEffect, useRef, useState } from "react";

export const SaveCrop = ({ file }: { file: File }) => {
  const cropper = useRef<CropperApi>(null);
  const running = useRef<AbortController | null>(null);
  const [loaded, setLoaded] = useState<File | null>(null);
  const [saving, setSaving] = useState(false);

  useEffect(() => () => running.current?.abort(), [file]);

  const save = async () => {
    if (!cropper.current || running.current) return;
    const controller = new AbortController();

    running.current = controller;
    setSaving(true);
    const result = await exportCrop(cropper.current, {
      type: "image/webp",
      maxSize: { width: 2048, height: 2048 },
      signal: controller.signal,
    }).catch((error: unknown) => {
      if (!controller.signal.aborted) console.error(error);

      return null;
    });

    running.current = null;
    setSaving(false);
    if (!result) return;
    const { blob, type } = result;
    const link = document.createElement("a");

    link.href = URL.createObjectURL(blob);
    const extension = type.replace("image/", "");

    link.download = `crop.${extension}`;
    link.click();
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
  };

  return (
    <>
      <Cropper.Root
        src={file}
        actionsRef={cropper}
        onLoad={() => setLoaded(file)}
      >
        <Cropper.Viewport
          style={{ maxWidth: 480, height: 360 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
      </Cropper.Root>
      <button
        type="button"
        disabled={loaded !== file}
        aria-disabled={saving}
        onClick={save}
      >
        Save
      </button>
    </>
  );
};
```

An export can fail on a very large photo, so the snippet catches it. [Errors](https://zriz.dev/docs/errors.md) lists the codes. `running` holds the export in flight. A second click does nothing, and a new file aborts it, so an old crop never downloads after the photo changed.

The demo above runs this export with the same `maxSize`, in the format you pick in its track, and uses the styles from the zriz site.

The export draws from the original, not the preview on screen. Without `maxSize`, the file has the crop's size in source pixels.

## Export a round avatar

With `shape="circle"` on `Root`, `exportCrop` reads the shape and returns a PNG with transparent corners. `exportImage(cropper, image)` exports a `LoadedImage` from `loadImage` instead. It does not read the shape, so pass `shape: "circle"` yourself. [Avatar](https://zriz.dev/docs/avatar.md) has the whole component.

## Choose size and format

- `type` `"image/jpeg" | "image/png" | "image/webp"` = `"image/jpeg"`

  The file format. A circle crop defaults to PNG. JPEG has no transparency, so clear pixels come out white. Safari cannot write WebP, so there you get JPEG, or PNG for a circle
- `quality` `number` = `0.9`

  JPEG and WebP quality, from 0 to 1. For a JPEG photo the default drops to the photo's own quality, so the file does not grow when you only crop it
- `maxSize` `{ width, height }`

  Scales the file down to fit inside this box, never up. A box with a side that is not a positive number is ignored
- `colorSpace` `"srgb" | "display-p3"` = `"srgb"`

  `"display-p3"` keeps wide colour when the photo came from a file or URL. It decodes on the main thread
- `shape` `"rect" | "circle"`

  `"rect"` cuts a rectangle. `"circle"` cuts a circle, with transparent corners in PNG and WebP and white ones in JPEG. Defaults to `shape` on `Root`
- `signal` `AbortSignal`

  Cancels a running export, which then rejects with `aborted`

`exportCrop` resolves to `{ blob, type, width, height, scaled, crop }`. Read `type` before you name the file, because a browser that cannot encode the type you asked for returns another one. `scaled` is `true` when the file came out smaller than the crop. Three things cause it: `maxSize`, the export size limit in [loading images](https://zriz.dev/docs/loading-images.md), and the retry at half and a quarter of the size when the browser cannot draw or encode the file. `crop` is the export plan the file was drawn from, at the scale it came out.

Exports run one at a time, in the order you call them. An aborted export stops waiting at once, but one the browser already started holds the next one back until it ends.

The browser draws pixels onto a canvas and encodes them, so EXIF, GPS, XMP, thumbnails, gain maps and bytes after the image never reach the file, and neither does 16-bit depth. Safari writes a small EXIF block of its own, with only the crop's size and colour space. [Security](https://zriz.dev/docs/security.md) covers the original, which keeps all of it.

## Export an exact size

`maxSize` never scales up, so a crop smaller than the box comes out smaller. Set `minSize` on `Root` to the same size, and every crop is at least that big in source pixels. On a photo at least that big, a square crop with `minSize` and `maxSize` both at 512 by 512 exports at 512 by 512.

`minSize` reads along the photo's own sides, so after a quarter turn a 1200 by 630 minimum can export at 630 by 1200. While you straighten a crop of another shape than the minimum, covering the photo comes first, so that crop can drop below it. The angle stays free while the minimum's diagonal fits along each side of the photo, so only the minimum's size next to the photo counts. A bigger minimum lowers `getRotationLimit()` below 45, and on a square photo that starts at a square minimum of about 71% of its side. The crop does not grow back on its own.

## Handle a failed export

`exportCrop` rejects with a `ZrizError`. [Errors](https://zriz.dev/docs/errors.md) lists each code an export can fail with, when it fires and how to fix it.

After the first photo appears, zriz loads the export code in the background, so the first export does not wait for a download.

## Show a small preview of the crop

Call `exportCrop` with a small `maxSize` in `onLoad` and in `onValueCommit`, and show the blob with `URL.createObjectURL()`. The first photo sends no commit until someone touches it, so `onLoad` draws the first preview. A later photo commits only when it has to refit the crop. Pass an `AbortController` `signal` and abort the last export on each commit, so a slow one never overwrites a newer one. Revoke the old object URL when a new preview replaces it:

```tsx
"use client";

import { exportCrop } from "@zriz/core/dom";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import { useEffect, useRef, useState } from "react";

export const CropWithPreview = ({ file }: { file: File }) => {
  const cropper = useRef<CropperApi>(null);
  const last = useRef<AbortController | null>(null);
  const [preview, setPreview] = useState<string | null>(null);

  useEffect(() => () => last.current?.abort(), []);

  useEffect(
    () => () => {
      if (preview) URL.revokeObjectURL(preview);
    },
    [preview]
  );

  const refresh = async () => {
    if (!cropper.current) return;
    last.current?.abort();
    const controller = new AbortController();

    last.current = controller;

    try {
      const { blob } = await exportCrop(cropper.current, {
        maxSize: { width: 256, height: 256 },
        signal: controller.signal,
      });

      if (!controller.signal.aborted) {
        setPreview(URL.createObjectURL(blob));
      }
    } catch (error) {
      if (!controller.signal.aborted) console.error(error);
    }
  };

  return (
    <>
      <Cropper.Root
        src={file}
        actionsRef={cropper}
        onLoad={() => void refresh()}
        onValueCommit={() => void refresh()}
      >
        <Cropper.Viewport
          style={{ maxWidth: 480, height: 360 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
      </Cropper.Root>
      {preview && <img src={preview} alt="Crop preview" />}
    </>
  );
};
```

## Cut the original on a server

To cut on a server, send the crop value from `getValue()` and let the server recompute the plan, as in [server crop](https://zriz.dev/docs/server-crop.md). Never trust a plan a client sends. `getPixelCrop()` returns the export plan in source pixels, or `null` until a photo loads, and each commit carries it as `details.pixelCrop`. Use it for your own tools, and read all of it, because `pixelRect` is `null` with a straighten angle or perspective.

- `width` `number`

  The output width in pixels, never below 1 px. `getPixelCrop()` takes a scale, 1 by default and for anything but a positive number or a scale that pushes a side past `Number.MAX_SAFE_INTEGER`
- `height` `number`

  The output height in pixels at the same scale
- `pixelRect` `{ left, top, width, height } | null`

  `{ left, top, width, height }` in the upright original, or `null` with a straighten angle or perspective
- `rotateExpand` `{ flop, degrees, dx, dy }`

  The mirror as `flop`, the quarter turns times 90 plus the straighten angle as `degrees`, and the crop's centre from the photo's centre after that whole turn as `dx` and `dy`, in source pixels
- `forward` `[a, b, c, d, e, f]`

  The map from source pixels to output pixels, as `[a, b, c, d, e, f]` in canvas `setTransform()` order. Alone it holds only when `homography` is `null`. With perspective, `output = forward(c + H(s - c))`, with `c` the centre of the image in source pixels
- `inverse` `[a, b, c, d, e, f]`

  The map from output pixels back to source pixels. Alone it holds only when `homography` is `null`. With perspective, the source pixel is `s = c + H⁻¹(inverse(output) - c)`, with `c` as above
- `homography` `number[] | null`

  The 3 by 3 perspective matrix `H`, as 9 numbers row by row, or `null` with no perspective. Divide by the third coordinate after you apply it

With no straighten and no perspective, the plan comes down to plain numbers that any tool can use: the rectangle, the flop and the turn. `pixelRect` is the truth for the file: its edges land on whole source pixels, so on a very small photo the frame on screen can sit up to half a source pixel from the file edge. A side never goes below 1 px, so a thin crop can come out slightly over `maxSize`.

`toSharp(plan, image)` from `@zriz/core` takes the plan and the upright photo's size and returns sharp steps with `kind` `"extract"` or `"affine"`. It ignores perspective without an error, so check `plan.homography` first. [Server crop](https://zriz.dev/docs/server-crop.md) shows the route for a level crop and a straightened one.
