# @polycyphers/spreadsheet

High-performance virtual-scrolled React spreadsheet with Excel I/O, a formula engine, and a plugin system.

**Commercial.** Production use requires a license — unlicensed instances render a visible watermark. A free 30-day trial is available, email-gated, no credit card. Details + pricing at [packages.polycyphers.com/spreadsheet](https://packages.polycyphers.com/spreadsheet).

## Highlights

- **Virtualized** — handles 100k+ rows without dropping frames
- **Excel I/O** — import/export XLSX with styles, formulas, merges
- **Formula engine** — 100+ built-in functions, custom registrations, cross-sheet refs
- **Typed cells** — text, number, currency, percentage, date, select, checkbox, formula
- **Sort / filter / pin / merge** — all standard grid affordances
- **Clipboard + undo/redo** — tab-delimited, batched history
- **Plugin system** — `CorePlugin<T>` extension points for custom columns, validation, toolbars
- **React 19 + TypeScript-first**, DaisyUI-compatible styles, tree-shakable

## Install

```bash
npm install @polycyphers/spreadsheet
```

## Setup

Import the stylesheet **once** in your app entry (e.g. `main.tsx`):

```tsx
import "@polycyphers/spreadsheet/css";
```

**Peer dependencies** — your app must have:
- `react >= 18`
- `react-dom >= 18`
- `exceljs >= 4.4.0` (optional — required only for Excel I/O)

**Styling requirements** (not peer deps, but expected by the CSS):
- Tailwind CSS + DaisyUI — the stylesheet references DaisyUI theme CSS variables

## Quick Start

```tsx
import { Spreadsheet, type SpreadsheetColumn } from "@polycyphers/spreadsheet";

interface Employee {
  id: number;
  name: string;
  department: string;
}

const columns: SpreadsheetColumn<Employee>[] = [
  { key: "id", label: "ID", width: 60 },
  { key: "name", label: "Name", width: 200 },
  { key: "department", label: "Department", width: 150 },
];

function MyPage() {
  const [data, setData] = useState<Employee[]>([]);

  return (
    <Spreadsheet
      data={data}
      columns={columns}
      persistKey="my-spreadsheet"
    />
  );
}
```

## Features

| Feature | Description |
|---------|-------------|
| **Virtual Scrolling** | Renders only visible rows — handles 100k+ rows smoothly |
| **Column Resize** | Drag-to-resize with localStorage persistence, batch resize for multi-selected columns |
| **Row Height Resize** | Drag row handles or double-click auto-fit; batch resize selected rows |
| **Sorting** | Click column headers, async transitions |
| **Filtering** | Global search + per-column dropdown filters |
| **Cell Selection** | Single cell, row, column, and range selection |
| **Inline Editing** | Text, number, select, checkbox, date, and custom editors |
| **Excel Import (Full Fidelity)** | Import `.xlsx` preserving fonts, colors, borders, alignment, number formats, merged cells, freeze panes, column widths, row heights, multi-sheet workbooks, and formulas |
| **Excel Export** | Export to `.xlsx` with styling (via ExcelJS) |
| **CSV Export** | RFC 4180-compliant CSV export |
| **Formula Engine** | 70+ built-in functions, dependency graph, circular reference detection, live evaluation |
| **Formula Bar** | View/edit active cell formula with commit button |
| **Multi-Sheet Tabs** | Automatic sheet tabs from imported workbooks; configurable prop-based tabs |
| **Cell Merging** | Merge cells across columns with O(1) lookup |
| **Freeze Panes** | Freeze left columns and top rows (from props or auto-applied from Excel import) |
| **Copy / Paste** | Ctrl+C/X/V with system clipboard |
| **Undo / Redo** | 100-step history, Ctrl+Z / Ctrl+Shift+Z |
| **Status Bar** | Sum, Avg, Count, Min, Max for multi-cell selections |
| **Plugin System** | Extensible lifecycle hooks for custom behavior |
| **Context Menu** | Right-click menus with visibility/disabled controls |
| **Session Persistence** | Column widths, sort state saved to storage |

## Excel Import — Full Fidelity

The spreadsheet can display an imported Excel (or Apple Numbers) file exactly as it appears in the original, including all formatting.

### Basic import (matched columns)

```tsx
const ref = useRef<SpreadsheetRef<MyRow>>(null);

// Trigger from a file input
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (file) ref.current?.importFromExcel(file);
};
```

### Free-form import (auto-generates columns from Excel headers)

When you don't know the column structure ahead of time, use `freeFormImport`. The spreadsheet will auto-generate column definitions from the Excel header row and display the file exactly as-is.

```tsx
<Spreadsheet
  data={data}
  columns={columns}
  excelConfig={{ freeFormImport: true }}
  ref={ref}
/>
```

### What is extracted

| Excel property | Extracted |
|---|---|
| Cell values | ✅ |
| Formulas (`=SUM(A1:B5)`, etc.) | ✅ evaluated live by formula engine |
| Font (bold, italic, underline, strikethrough, size, color, family) | ✅ |
| Background fill color | ✅ |
| Text alignment (horizontal + vertical) | ✅ |
| Word wrap | ✅ |
| Borders (top, bottom, left, right — style + color) | ✅ |
| Number formats (%, currency, decimals) | ✅ displayed + applied |
| Merged cells | ✅ |
| Freeze panes | ✅ |
| Column widths | ✅ |
| Row heights | ✅ |
| All sheets (multi-sheet workbook) | ✅ tabs rendered automatically |
| Rich text | ✅ (text content extracted) |
| Dates | ✅ |

### Types for custom import processing

```tsx
import type {
  CellStyle,
  CellStyleMap,
  ExcelImportMeta,
  ExcelSheetData,
} from "@polycyphers/spreadsheet";
```

## Exports

### Component

```tsx
import { Spreadsheet } from "@polycyphers/spreadsheet";
```

### Sub-Components

```tsx
import { SheetTabs, LongTextDialog } from "@polycyphers/spreadsheet";
```

### Hooks

```tsx
import {
  useVirtualScroll,
  useColumnResize,
  useRowHeightResize,
  useSelection,
  useSorting,
  useFiltering,
  useExcelIO,
  useFormulaEngine,
  usePinning,
  useMergedCells,
  useStatusBar,
  useClipboard,
  useUndoRedo,
  useDynamicColumns,
  useOverflowDetection,
} from "@polycyphers/spreadsheet";
```

### Plugin Utilities

```tsx
import { usePlugins, createPlugin, composePlugins } from "@polycyphers/spreadsheet";
```

### Feature Hooks

```tsx
import {
  useHourValidation,
  useCostCodes,
  useInlineRowCreation,
  useContextMenu,
} from "@polycyphers/spreadsheet";
```

### Types

```tsx
import type {
  // Core
  SpreadsheetColumn,
  SpreadsheetProps,
  SpreadsheetRef,
  SpreadsheetMode,              // "view" | "edit" | "create"
  SpreadsheetVariant,           // "default" | "compact" | "pointage" | "tasks"
  SpreadsheetExcelConfig,
  SpreadsheetFeatures,
  CellEditorType,               // "text" | "number" | "select" | "checkbox" | "date" | "custom"

  // Selection & navigation
  CellPosition,
  SelectionRange,
  SelectionMode,                // "cell" | "row" | "column"
  NavigationDirection,          // "down" | "up" | "right" | "left"
  PersistedSheetState,

  // Sheet tabs
  SheetTab,

  // Excel import
  CellBorder,
  CellStyle,
  CellStyleMap,
  ExcelImportMeta,
  ExcelSheetData,

  // Feature configs
  InlineRowCreationConfig,
  RowHeightResizeConfig,
  ContextMenuConfig,
  ContextMenuItem,
  CostCodeDialogConfig,
  HourValidationConfig,
  HourValidationError,
  CalculatedFieldConfig,
  DirtyTrackingConfig,
  DynamicColumnConfig,
  MultiRowSelectionConfig,
  PositionSummaryConfig,

  // Plugin system
  SpreadsheetPlugin,
  PluginContext,
  CellInfo,
} from "@polycyphers/spreadsheet";
```

Hook option/result types (`UseVirtualScrollOptions`, `VirtualScrollResult`, `UseColumnResizeOptions`, `ColumnResizeResult`, `UseSelectionOptions`, `SelectionResult`, `UseSortingOptions`, `SortingResult`, `UseFilteringOptions`, `FilteringResult`, `UseExcelIOOptions`, `ExcelIOResult`, `UseClipboardOptions`, `ClipboardResult`, `UseUndoRedoOptions`, `UndoRedoResult`, `CellChange`, `UndoRedoEntry`, `UsePinningOptions`, `PinningResult`, `PinningConfig`, `UseMergedCellsOptions`, `MergedCellsResult`, `MergedCellRange`, `MergedCellInfo`, `UseStatusBarOptions`, `StatusBarResult`, `StatusBarStats`, `UseFormulaEngineOptions`, `FormulaEngineResult`, `UseRowHeightResizeOptions`, `RowHeightResizeResult`, `UseDynamicColumnsOptions`, `DynamicColumnsResult`, `UseHourValidationOptions`, `UseHourValidationResult`, `UseCostCodesOptions`, `UseCostCodesResult`, `UseInlineRowCreationOptions`, `UseInlineRowCreationResult`, `UseContextMenuOptions`, `UseContextMenuResult`, `UsePluginsOptions`, `UsePluginsResult`, `PointagePluginConfig`, `TasksPluginConfig`) are all exported for advanced composition.

### Formula Engine

```tsx
import {
  FormulaEngine,               // standalone class
  DependencyGraph,             // cell dependency graph
  Errors, FormulaError, isError,
  builtinFunctions, functionCatalog,
  colToNum, numToCol, cellId, parseCellId, adjustFormulaRefs,
} from "@polycyphers/spreadsheet";

import type {
  CellValue,
  CellMatrix,
  CellData,
  CellRef,
  FormulaFunction,
  FunctionInfo,
  ASTNode,
} from "@polycyphers/spreadsheet";
```

### Editor sub-components

```tsx
import {
  CellTextEditor,
  CellSelectEditor,
  OverflowCellContent,
} from "@polycyphers/spreadsheet";

import type {
  CellTextEditorProps,
  CellSelectEditorProps,
  OverflowCellContentProps,
} from "@polycyphers/spreadsheet";
```

### Utilities

```tsx
import {
  columnIndexToLetter,
  getValue,
  useOverflowDetection,
} from "@polycyphers/spreadsheet";
```

### Constants

```tsx
import {
  DEFAULT_ROW_HEIGHT,          // 32
  DEFAULT_OVERSCAN,            // 8
  DEFAULT_MIN_BUFFER_ROWS,     // 8
  DEFAULT_MIN_RENDER_ROWS,     // 30
  DEFAULT_COLUMN_MIN_WIDTH,    // 60
  DEFAULT_COLUMN_MAX_WIDTH,    // 600
} from "@polycyphers/spreadsheet";
```

### License

```tsx
import {
  setLicense,
  getLicenseStatus,
  isLicensed,
  KARAMENT_INTERNAL_KEY,
} from "@polycyphers/spreadsheet";

import type { LicenseStatus } from "@polycyphers/spreadsheet";
```

## Props Reference

### Core

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `T[]` | — | Row objects |
| `columns` | `SpreadsheetColumn<T>[]` | — | Column definitions |
| `mode` | `"view" \| "edit" \| "create"` | `"view"` | Interaction mode |
| `editContextKey` | `string \| number \| null` | — | Changing this flushes pending edits (e.g. switching record) |
| `loading` | `boolean` | `false` | Show loading state |
| `emptyMessage` | `string` | — | Message shown when `data` is empty |
| `containerClassName` | `string` | — | Extra class on the outer container |
| `persistKey` | `string` | — | localStorage/sessionStorage key for column widths, sort, filters |
| `variant` | `"default" \| "compact" \| "pointage" \| "tasks"` | `"default"` | Layout density variant |

### Layout / Virtualization

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `rowHeight` | `number \| (row,i)=>number` | `32` | Fixed or per-row height |
| `maxHeight` | `number \| string` | — | Scroll region max height |
| `overscan` | `number` | `8` | Extra rows rendered outside viewport |
| `minBufferRows` | `number` | `8` | Minimum buffered rows above/below viewport |
| `minRenderRows` | `number` | `30` | Minimum rendered rows (prevents underdraw) |
| `pinning` | `{columns?,rows?}` | — | Freeze left columns / top rows |
| `mergedCells` | `[{row,col,rowspan,colspan}]` | — | Merged cell ranges |

### Slots

| Prop | Type | Description |
|------|------|-------------|
| `toolbar` | `ReactNode` | Custom formula bar toolbar |
| `toolbarLeft` | `ReactNode` | Content pinned to left of toolbar |
| `formulaBarStart` | `ReactNode` | Content before formula bar |
| `hideFormulaBar` | `boolean` | Hide the formula bar entirely |
| `footer` | `ReactNode` | Footer content below the grid |
| `slotBelowHeader` | `ReactNode` | Content injected below the column headers |
| `summaryRow` | `(rows, ctx?) => ReactNode` | Totals/summary row renderer |
| `sheetTabs` | `SheetTab[]` | Multi-sheet tabs |
| `activeSheetTab` | `string` | Active sheet tab key |
| `onSheetTabChange` | `(key) => void` | Tab change handler |

### Callbacks

| Prop | Type | Description |
|------|------|-------------|
| `onCellChange` | `(rowIndex, columnKey, value, row) => void` | Single cell value changed |
| `onRowChange` | `(rowIndex, row) => void` | Any cell on a row changed |
| `onDataChange` | `(rows) => void` | Full dataset changed (paste, import, undo, etc.) |
| `onRowClick` | `(row, index) => void` | Row clicked |
| `onRowDoubleClick` | `(row, index) => void` | Row double-clicked |
| `onRowContextMenu` | `(row, index, event) => void` | Row right-clicked |
| `onSelectionChange` | `(cell \| null, row?) => void` | Active cell/row selection changed |
| `onSearchChange` | `(search) => void` | Global search text changed |

### Row customization

| Prop | Type | Description |
|------|------|-------------|
| `rowClassName` | `(row, i) => string \| undefined` | Extra class per row |
| `rowNumberRender` | `(row, i) => ReactNode` | Custom row number cell |
| `rowNumberTitle` | `(row, i) => string \| undefined` | Tooltip for row number |
| `isCellEditable` | `(row, column, i) => boolean` | Per-cell edit guard (overrides `column.editable`) |
| `actionsRender` | `(row, i) => ReactNode` | Right-hand actions column renderer |
| `actionsColumnWidth` | `number` | Width of actions column |
| `actionsColumnResizable` | `boolean` | Allow actions column to be resized |
| `getRowId` | `(row, i) => string \| number` | Stable row identity (used by `scrollToRowId`) |
| `scrollToRowId` | `string \| number \| null` | Imperatively scroll to a row by id |

### Feature flags

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `allowKeyboardNavigation` | `boolean` | `true` | Arrow/Tab/Enter navigation |
| `allowColumnResize` | `boolean` | `true` | Drag-to-resize column widths |
| `allowFilters` | `boolean` | `true` | Column filter dropdowns |
| `allowSorting` | `boolean` | `true` | Column header sort |
| `showStatusBar` | `boolean` | `false` | Sum/Avg/Count/Min/Max bar |

### Excel I/O

| Prop | Type | Description |
|------|------|-------------|
| `excelConfig.fileName` | `string` | Download filename for export |
| `excelConfig.sheetName` | `string` | Sheet name for export |
| `excelConfig.freeFormImport` | `boolean` | Auto-generate columns from Excel headers |
| `excelConfig.exportBuilder` | `fn` | Custom workbook builder |
| `excelConfig.importParser` | `fn` | Custom import parser (bypasses fidelity import) |

### Extensibility

| Prop | Type | Description |
|------|------|-------------|
| `plugins` | `SpreadsheetPlugin<T>[]` | Lifecycle/event/render plugin array |
| `features` | `SpreadsheetFeatures<T>` | Feature config bag: `inlineRowCreation`, `rowHeightResize`, `contextMenu`, `multiRowSelection`, `costCodeDialog`, `hourValidation`, `positionSummary`, `calculatedFields`, `dirtyTracking`, `dynamicColumns` |
| `aiId` | `string` | AI integration id (for external assistants) |

### SpreadsheetRef methods

```tsx
const ref = useRef<SpreadsheetRef<T>>(null);

ref.current.exportToExcel()         // download .xlsx
ref.current.importFromExcel(file)   // load file with full fidelity
ref.current.exportToCSV()           // download .csv
ref.current.undo()
ref.current.redo()
ref.current.getData()               // get current rows
ref.current.resetColumnWidths()
```

## Plugin System

Plugins implement the `SpreadsheetPlugin<T>` interface. Every hook is optional — implement only what you need. Return `true` from an event hook to prevent the default behavior.

### Plugin lifecycle & hook surface

| Hook | Signature | Purpose |
|------|-----------|---------|
| `onMount` | `(context) => void \| (() => void)` | Mount hook; return function runs on unmount |
| `onUnmount` | `() => void` | Explicit unmount callback |
| `onDataChange` | `(data, prevData) => void` | Fires when the row array reference changes |
| `renderCellOverlay` | `(cell) => ReactNode` | Overlay UI rendered above a cell |
| `renderRowOverlay` | `(row, i) => ReactNode` | Overlay rendered per row |
| `renderToolbarExtension` | `(context) => ReactNode` | Injects content into the formula bar toolbar |
| `renderFooterExtension` | `(context) => ReactNode` | Injects content into the footer area |
| `onCellClick` / `onCellDoubleClick` | `(cell, event) => boolean \| void` | Return `true` to prevent default |
| `onCellChange` | `(cell, newValue, prevValue) => any` | Return a value to transform the committed value |
| `onRowClick` | `(row, i, event) => boolean \| void` | Return `true` to prevent default |
| `onKeyDown` | `(event, cell) => boolean \| void` | Return `true` to prevent default |
| `onContextMenu` | `(row, i, event) => boolean \| void` | Return `true` to prevent default |
| `transformData` | `(data) => data` | Last-mile data transform (sort/filter/inject synthetic rows) |
| `transformColumns` | `(columns) => columns` | Last-mile column transform |
| `validateCell` | `(cell, newValue) => { valid, message? }` | Veto a cell edit |

`PluginContext<T>` provides `{ data, columns, mode, selectedCell, selectionRange, updateCellValue, setSelectedCell, getRow, getColumn, forceUpdate }`.

### Building a plugin

```tsx
import { createPlugin, type SpreadsheetPlugin } from "@polycyphers/spreadsheet";

const uppercasePlugin: SpreadsheetPlugin<MyRow> = createPlugin({
  name: "uppercase-names",
  onCellChange: (cell, newValue) => {
    if (cell.columnKey === "name" && typeof newValue === "string") {
      return newValue.toUpperCase();
    }
  },
});

<Spreadsheet data={rows} columns={cols} plugins={[uppercasePlugin]} />
```

Combine plugins with `composePlugins(a, b, c)`.

### Built-in plugins

| Plugin | Factory | Exported columns | Use case |
|--------|---------|------------------|----------|
| **Pointage** | `createPointagePlugin(config)` | `POINTAGE_COLUMNS` | Hour validation, bulk regular hours, position summary, salary type grouping |
| **Tasks** | `createTasksPlugin(config)` | `TASK_COLUMNS` | Inline row creation, context menu, row height resize, status/category dropdowns |

Config types: `PointagePluginConfig`, `TasksPluginConfig`.

## Variants

The `variant` prop adjusts layout density:

| Variant | Description |
|---------|-------------|
| `"default"` | Standard spreadsheet |
| `"compact"` | Reduced row height and padding |
| `"pointage"` | Optimized for attendance/hours entry |
| `"tasks"` | Optimized for task management |

## Formula Engine

Supports 70+ Excel-compatible functions. Introspect the full set at runtime via the exported `functionCatalog`.

**Math:** SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, ROUND, ROUNDUP, ROUNDDOWN, CEILING, FLOOR, ABS, POWER, SQRT, MOD, INT, PRODUCT, PI, RAND, SIGN  
**Statistical:** MEDIAN, MODE, STDEV, VAR, LARGE, SMALL, RANK, PERCENTILE  
**Logical:** IF, IFS, AND, OR, NOT, IFERROR, SWITCH  
**Text:** CONCATENATE, LEFT, RIGHT, MID, LEN, UPPER, LOWER, TRIM, SUBSTITUTE, FIND, SEARCH, TEXT, VALUE  
**Date:** TODAY, NOW, DATE, YEAR, MONTH, DAY, DATEDIF, DAYS  
**Lookup:** VLOOKUP, HLOOKUP, INDEX, MATCH  
**Financial:** PMT, FV, PV, RATE  
**Information:** ISBLANK, ISERROR, ISNUMBER, ISTEXT, SUMIF, COUNTIF, AVERAGEIF

Cell references support absolute (`$A$1`), relative (`A1`), and mixed forms.

## Consumer integration

### Required app setup

1. **Install the package** — `npm install @polycyphers/spreadsheet`.
2. **Install peer deps** in the app — `react >= 19.2.5`, `react-dom >= 19.2.5`, and `exceljs >= 4.4.0` if Excel I/O is used.
3. **Import the stylesheet once** in the app entry:
   ```tsx
   // main.tsx
   import "@polycyphers/spreadsheet/css";
   ```
4. **Tailwind + DaisyUI** must be configured in the host app — the stylesheet consumes DaisyUI theme CSS variables (e.g. `--b1`, `--bc`, `--p`).

### Vite alias — live source development

Use this pattern in a consumer's `vite.config.ts` to skip the build step while iterating on the package. Verified in `PORTAL/Claims/vite.config.ts`:

```ts
import path from "path";

const spreadsheetSourceRoot = path.resolve(
  __dirname,
  "../../karament-spreadsheet/src"
);

export default defineConfig({
  resolve: {
    dedupe: ["react", "react-dom", "@iconify/react"],
    alias: [
      { find: "@polycyphers/spreadsheet/css", replacement: path.join(spreadsheetSourceRoot, "spreadsheet.css") },
      { find: "@polycyphers/spreadsheet",      replacement: path.join(spreadsheetSourceRoot, "index.ts") },
    ],
  },
  optimizeDeps: {
    exclude: ["@polycyphers/spreadsheet"],
  },
});
```

Key points:

| Setting | Why |
|---------|-----|
| `resolve.dedupe: ["react", "react-dom", "@iconify/react"]` | Prevents duplicate React copies (needed for hooks to work when package is resolved from source) |
| `alias` for `@polycyphers/spreadsheet` | Points import directly at `src/index.ts` — no rebuild to pick up edits |
| Separate alias for `/css` | CSS lives outside the JS entry; must be aliased explicitly |
| `optimizeDeps.exclude` | Keeps Vite from pre-bundling the package when using the alias |

Without the alias, consumers just use the published `dist/` output — no special config required.

### Adopter gotchas

- **Do not import `@polycyphers/spreadsheet/css` inside the package's own JS** — imports happen once in the app entry, never transitively from the library. Breaking this splits the CSS out of the single-file bundle.
- **React version mismatch** is the #1 failure mode when using the Vite alias (duplicate copies → `Invalid hook call`). Always add `resolve.dedupe`.
- **ExcelJS is optional but `peerDependenciesMeta` marks it so** — the app gets a bundler warning if it's missing and Excel I/O is called. Install it if `importFromExcel` / `exportToExcel` are used.
- **`persistKey`** writes column widths, sort state, and filters to storage. Use a unique key per page; reusing a key across pages cross-contaminates state.
- **`editContextKey`** must be bumped when the underlying record changes (e.g. navigating from record A to record B with the same page mounted). Without it, pending edits from A will flush into B.
- **Merged cells and pinning from Excel import** override any `mergedCells` / `pinning` props on the component — imported files are treated as authoritative.
- **CSS is one file** (`spreadsheet.css`, not code-split). Size is predictable; import it only once.
- **Icons** — the package ships its own `@iconify/react` + `@iconify/icons-lucide` runtime dependencies. If the app also uses Iconify, add it to `resolve.dedupe` (see above) to avoid two registries.
- **Free-form Excel import** re-generates `columns` from the file header row — any column props you pass in are discarded when `excelConfig.freeFormImport: true`.

## Updating

When a new version is tagged:

```bash
npm install @polycyphers/spreadsheet
```

## Development

```bash
git clone https://github.com/melejel2/karament-spreadsheet.git
cd karament-spreadsheet
npm install
npm run build        # Build dist/
npm run dev          # Watch mode
npm run type-check   # TypeScript validation
```

## License

Commercial. See [LICENSE.md](./LICENSE.md) and [EULA.md](./EULA.md). Trial + pricing at [packages.polycyphers.com/spreadsheet](https://packages.polycyphers.com/spreadsheet).

---

Polycyphers &copy; 2025-2026 | All rights reserved
