> For the complete documentation index, see [llms.txt](https://docs.rowsncolumns.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rowsncolumns.app/configuration/features/print-preview.md).

# Print preview

`PrintPreviewDialog` is a self-contained, full-screen modal that paginates the active sheet into printable page-sized blocks, renders the same blocks into a hidden iframe with the appropriate `@page` rules, and triggers the browser print dialog. Modelled on Google Sheets' Print pane — same sidebar controls, same paginated preview area, same paper sizes.

## Is this a breaking change?

**No.** Print preview is purely additive:

* New named export from `@rowsncolumns/spreadsheet`: `PrintPreviewDialog` and `PrintSettings` / `PaperSizeId` / related types.
* `ButtonPrint`'s API is unchanged — it's still a thin `ToolbarIconButton`. The only thing that updates is **your `onClick` handler** (you used to call `window.print()`, now you open the dialog).
* No props on `useSpreadsheetState`, `CanvasGrid`, or any existing component change. If you don't render `PrintPreviewDialog`, nothing happens.

Upgrade path: drop the new dialog into your app and rewire `ButtonPrint`. That's it.

## Why a dialog instead of `window.print()`?

`window.print()` prints the **entire host page** — your toolbar, formula bar, sidebar, panels, ads, whatever else lives on the route. That's almost never what a user wants when they hit ⌘P on a spreadsheet.

The dialog instead:

1. Paginates the **sheet data** into page-sized blocks at the chosen paper size + orientation + scale.
2. Renders each block as a self-contained HTML table styled from `getEffectiveFormat`.
3. On **Print**, snapshots only those blocks into a hidden iframe alongside the matching `@page { size: ... }` CSS, then calls `iframe.contentWindow.print()`. The browser print dialog shows the actual paginated spreadsheet, nothing else from your app.

## Integration

The dialog reads everything it needs from values you already have on `useSpreadsheetState`. No adapter object; the props are flat.

```tsx
import { useState, useEffect } from "react";
import {
  PrintPreviewDialog,
  ButtonPrint,
} from "@rowsncolumns/spreadsheet";
import { useSpreadsheetState } from "@rowsncolumns/spreadsheet-state";

function App() {
  const {
    sheets,
    activeSheetId,
    selections,
    getFormattedValue,
    getEffectiveFormat,
    getDataRowCount,
    getDataColumnCount,
    getCellData,
  } = useSpreadsheetState({ /* ... */ });

  const [isPrintOpen, setPrintOpen] = useState(false);

  // ⌘P / Ctrl+P hijack + Esc dismiss. Kept in user space rather than
  // baking into `PrintPreviewDialog` so an embedding app can decide
  // whether Esc should dismiss other overlays first.
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "p") {
        e.preventDefault();
        setPrintOpen(true);
        return;
      }
      if (e.key === "Escape" && isPrintOpen) {
        e.preventDefault();
        setPrintOpen(false);
      }
    };
    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  }, [isPrintOpen]);

  return (
    <>
      <ButtonPrint onClick={() => setPrintOpen(true)} />

      <PrintPreviewDialog
        isOpen={isPrintOpen}
        onClose={() => setPrintOpen(false)}
        title="My Workbook"
        sheets={sheets}
        activeSheetId={activeSheetId}
        selections={selections}
        getFormattedValue={getFormattedValue}
        getEffectiveFormat={getEffectiveFormat}
        getDataRowCount={getDataRowCount}
        getDataColumnCount={getDataColumnCount}
        getCellData={getCellData}
      />
    </>
  );
}
```

That's the whole integration. The dialog manages its own settings state internally — the host doesn't need to persist or store anything. Settings carry over across open/close cycles within the same mount, so a user who picks A4 + Landscape and reopens still sees A4 + Landscape. If you want every open to start fresh, pass a changing `key` (e.g. `key={isPrintOpen ? "open" : "closed"}`) — React will remount the component and reset state to defaults.

## `PrintPreviewDialog` props

| Prop                 | Type                                | Required | Purpose                                                                                                                                                |
| -------------------- | ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `isOpen`             | `boolean`                           | ✅        | Controlled visibility.                                                                                                                                 |
| `onClose`            | `() => void`                        | ✅        | Fires on Cancel, the close icon, or after Print. (Esc binding lives in user space — see Integration above.)                                            |
| `sheets`             | `Sheet[]`                           | ✅        | Full sheet list — same array on `useSpreadsheetState.sheets`. Drives both the active-sheet lookup and the "Workbook" scope.                            |
| `activeSheetId`      | `number`                            | ✅        | Which sheet to preview. The dialog looks it up in `sheets`.                                                                                            |
| `selections`         | `SelectionArea[]?`                  |          | The first selection's range powers the "Selection" scope. Pass `useSpreadsheetState.selections` straight through; omit / pass `[]` to hide the option. |
| `title`              | `string?`                           |          | Workbook display name. Shown in the header strip when the "Workbook title" header field is enabled.                                                    |
| `getFormattedValue`  | `(sheetId, row, col) => string`     | ✅        | From `useSpreadsheetState`.                                                                                                                            |
| `getEffectiveFormat` | `(sheetId, row, col) => CellFormat` | ✅        | From `useSpreadsheetState`.                                                                                                                            |
| `getDataRowCount`    | `(sheetId) => number?`              |          | When present, restricts the print extent to the last non-empty row (matches Google Sheets). Falls back to `sheet.rowCount`.                            |
| `getDataColumnCount` | `(sheetId) => number?`              |          | Same as above for columns.                                                                                                                             |
| `getCellData`        | `(sheetId, row, col) => CellData?`  |          | Surfaces the "Show notes" red corner marker. Without it the toggle still renders but produces no markers.                                              |

The dialog pulls everything else off the `Sheet` it resolves — `title` (display name), `merges`, `frozenRowCount` / `frozenColumnCount`, and per-row / per-column sizes from `rowMetadata` / `columnMetadata`.

## Sidebar settings

The dialog manages its own settings state — defaults match Google Sheets where they line up.

| Setting                   | Options                                                                                                        |
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
| **Print scope**           | Current sheet · Workbook (when `sheets.length > 1`) · Selection (when `selections[0]` is set)                  |
| **Paper size**            | Letter · Tabloid · Legal · Statement · Executive · Folio · A3 · A4 · A5 · B4 · B5                              |
| **Orientation**           | Portrait · Landscape                                                                                           |
| **Scale**                 | Normal (100 %) · Fit to width · Fit to page · Custom (10 – 400 %)                                              |
| **Margins**               | Normal · Narrow · Wide · Custom (per-edge px)                                                                  |
| **Show gridlines**        | toggle                                                                                                         |
| **Show notes**            | toggle — paints a red corner triangle on noted cells (needs `getCellData`)                                     |
| **Page order**            | Over, then down (default) · Down, then over                                                                    |
| **Horizontal alignment**  | Left · Center · Right                                                                                          |
| **Vertical alignment**    | Top · Middle · Bottom                                                                                          |
| **Headers & footers**     | Page numbers · Workbook title · Sheet name · Current date · Current time — auto-placed across the 3-zone strip |
| **Repeat frozen rows**    | toggle, disabled when `Sheet.frozenRowCount` is 0 / unset                                                      |
| **Repeat frozen columns** | toggle, disabled when `Sheet.frozenColumnCount` is 0 / unset                                                   |

## Pagination

The pagination engine (`computePageLayout`) walks the cell range with the active scale, splitting rows and columns into bands whose summed scaled dimensions fit the page content area (paper minus margins minus optional header/footer strips, minus a 1 px reserve for the closing cell border).

* `Over, then down`: outer = row bands, inner = column bands — page order goes left-to-right across the first row band, then advances. (Google Sheets' default.)
* `Down, then over`: outer = column bands, inner = row bands — page order goes top-to-bottom within the first column band, then advances. (Excel's `pageOrder="downThenOver"` default.)

Frozen rows / columns, when their toggle is on, are subtracted from the content area on every page and prepended as a fixed prefix to each band — every page renders with the same frozen header rows / leftmost columns.

## What's NOT in this release

Deferred so we could ship the core flow. None block the integration above.

* **Set custom page breaks** — drag-edit page breaks UI. The underlying `<rowBreaks>` / `<colBreaks>` XLSX metadata still round-trips on import/export.
* **Edit custom fields** — the Google-Sheets 3-zone (`&L` / `&C` / `&R`) header / footer composer. v1 auto-places enabled fields.
* **Show notes — note text** — the toggle ships and marks noted cells with a red corner triangle, but the note text isn't emitted in a trailing endnote section (Google Sheets does this; deferred).

## Files

* `apps/spreadsheet/components/print-preview/settings.ts` — `PrintSettings` model + paper-size / margin constants.
* `apps/spreadsheet/components/print-preview/use-page-layout.ts` — pure pagination math; band slicing, scale resolution.
* `apps/spreadsheet/components/print-preview/cell-styles.ts` — maps `CellFormat` to inline CSS for the printed cells.
* `apps/spreadsheet/components/print-preview/page-block.tsx` — renders one page as a styled HTML table.
* `apps/spreadsheet/components/print-preview/print-preview-dialog.tsx` — the modal, sidebar, and pagination orchestration.
* `apps/spreadsheet/components/print-preview/run-print.ts` — hidden-iframe `window.print()` driver with `@page` rules.
