npm package: @gulibs/beautify-qrcode-hooks
Description: React Hooks，基于 [qr-code-styling](https://www.npmjs.com/package/qr-code-styling)，支持单码/批量生成、Canvas 与 SVG 双模式预览、内置背景图与文字叠加插件、PNG/JPEG/WebP/SVG 导出，以及可扩展的插件体系。
Latest version: 0.0.5

# @gulibs/beautify-qrcode-hooks

React Hooks，基于 [qr-code-styling](https://www.npmjs.com/package/qr-code-styling)，支持单码/批量生成、Canvas 与 SVG 双模式预览、内置背景图与文字叠加插件、PNG/JPEG/WebP/SVG 导出，以及可扩展的插件体系。

---

## 安装

```bash
pnpm add @gulibs/beautify-qrcode-hooks qr-code-styling
# 对等依赖：react >=18, react-dom >=18
```

---

## 快速开始

```tsx
import { useRef, useEffect } from 'react'
import { useBeautifyQRCode } from '@gulibs/beautify-qrcode-hooks'

function QRDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const { drawPreview, download } = useBeautifyQRCode({
    content: 'https://example.com',
    dimensions: { width: 300, height: 300 },
  })

  useEffect(() => {
    if (canvasRef.current) drawPreview(canvasRef.current)
  }, [drawPreview])

  return (
    <>
      <canvas ref={canvasRef} style={{ width: 300, height: 300 }} />
      <button onClick={() => download({ name: 'qrcode', extension: 'png' })}>
        下载 PNG
      </button>
    </>
  )
}
```

> `<canvas>` 的 CSS 宽高（`style`）控制显示尺寸，`canvas.width/height` 属性由 Hook 内部自动设置为 CSS 尺寸 × DPR，无需手动赋值。

---

## 核心概念

### 受控 vs 非受控设置

Hook 支持两种状态管理模式：

**受控模式**（推荐）：将外部 `settings` 对象直接传入，Hook 始终使用该值，无需调用 `updateSettings`。外部状态变化时直接触发重新渲染。

```tsx
const settings = useMemo<BeautificationSettings>(() => ({
  qrCodeStyle: { foregroundColor: '#333', margin: 8 },
}), [someState])

const { drawPreview } = useBeautifyQRCode({ content, dimensions, settings })
```

**非受控模式**：不传 `settings`，传入 `initialSettings` 作为初始值，之后通过 `updateSettings` 修改内部状态。适合 Hook 内部完全自管理状态的场景。

```tsx
const { drawPreview, updateSettings } = useBeautifyQRCode({
  content,
  dimensions,
  initialSettings: { qrCodeStyle: { margin: 8 } },
})

// 更新单个字段
updateSettings(prev => ({
  ...prev,
  qrCodeStyle: { ...prev.qrCodeStyle, margin: 16 },
}))
```

> 同时传入 `settings` 和 `initialSettings`/`updateSettings` 时，`settings` 优先，`updateSettings` 仍可写入内部状态但不影响外部受控值。

---

### DPR 与像素一致性

**预览（Canvas 模式）**：

- `drawPreview` 自动读取 `window.devicePixelRatio`，将 canvas buffer 设置为 CSS 尺寸 × DPR（例如 300 px @ DPR=2 → buffer 600×600）。
- `ctx.scale(dpr, dpr)` 使所有绘制坐标保持 CSS 像素，Retina 屏幕下自动锐利。
- Canvas 的 CSS 显示尺寸（`style.width/height`）由业务侧的 JSX 控制，Hook 不修改。

**导出（PNG/JPEG/WebP）**：

- `getRawData`、`download`、`exportAsDataURL` 均接受 `scale` 参数（默认 **2**），以 `dimensions × scale` 的物理像素分辨率导出，确保下载图与预览清晰度一致。

**SVG 模式**：矢量格式，任意缩放不失真，DPR 与 scale 对 SVG 导出均无意义。

---

### Canvas 与 SVG 坐标系一致性

两种预览模式共享相同的坐标系：

- **外层容器**尺寸为 `dimensions.width × dimensions.height`。
- **QR 区域**（`qrX`, `qrY`, `qrSize`）由 `computePreviewRect` 计算，扣除文字带后居中排列。
- Canvas 中，QR 图像绘制到 `ctx.drawImage(img, qrX, qrY, qrSize, qrSize)`；SVG 中，QR SVG 元素包裹在 `<g transform="translate(qrX, qrY)">` 中。
- 插件的 `svgExtension` 收到的 `options.width/height` 为**外层容器尺寸**（不是 `qrSize`），确保边框、装饰类扩展坐标与 Canvas 插件对齐。

---

### 无闪烁渲染

`drawPreview` 采用**双缓冲**策略：

1. 所有异步渲染（生成 QR、加载图片、插件 beforeRender/afterRender）在**离屏 canvas** 上完成。
2. 完成后同步执行 `visibleCanvas.width = offscreen.width` + `ctx.drawImage(offscreen, 0, 0)`，两行代码在同一 JS 任务内，浏览器无法在两者之间插入绘制帧，因此**不出现空白闪烁**。

**竞态保护**：每次调用 `drawPreview`/`renderPreviewToSvg` 都分配单调递增的渲染 ID；若更新的调用在旧调用完成前到达，旧调用在每个异步检查点放弃（不绘制），确保屏幕永远显示最新状态。

---

## useBeautifyQRCode

单码 QR 生成与预览的主 Hook。

### 入参 `UseBeautifyQRCodeOptions`

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `content` | `string` | — | **受控** QR 内容。传入后 `setContent` 为 no-op。 |
| `contentForQR` | `string` | `''` | 未传 `content` 时的默认内容（仅用于初始化内部 state）。 |
| `settings` | `BeautificationSettings` | — | **受控**样式配置。传入后 Hook 直接使用，忽略内部 state 与 `initialSettings`。 |
| `initialSettings` | `BeautificationSettings` | `{}` | 非受控模式的初始样式（仅初始化内部 state 一次）。 |
| `dimensions` | `{ width: number; height: number }` | `{ width: 300, height: 300 }` | 预览与导出的容器尺寸（CSS 像素）。 |
| `previewMode` | `'canvas' \| 'svg'` | `'canvas'` | 用于消费侧初始化 mode 状态的提示值，不影响 Hook 内部行为。 |
| `extension` | `ExtensionFunction` | — | 单个 SVG 扩展函数，在所有插件 svgExtension 之后执行。 |
| `plugins` | `BeautifyQRCodePlugin[]` | — | 静态插件数组，Hook 内部自动构建 registry。 |
| `pluginRegistry` | `PluginRegistry \| null` | — | 外部 registry（优先于 `plugins`）；适合运行时 register/unregister。 |
| `onError` | `QRCodeErrorCallback` | — | 预览或导出出错时回调，接收 `(error, { source })` 。 |

> `plugins` 与 `pluginRegistry` 同时传入时，`pluginRegistry` 生效，`plugins` 被忽略。

---

### 返回值 `UseBeautifyQRCodeReturn`

#### 状态

| 属性 | 类型 | 说明 |
|------|------|------|
| `settings` | `BeautificationSettings` | 当前生效的完整配置（受控时为传入值，非受控时为内部 state）。 |
| `updateSettings` | `(value: BeautificationSettings \| ((prev) => BeautificationSettings)) => void` | 更新内部 state（受控模式下内部 state 不影响渲染，但仍可写入；非受控模式的主要更新接口）。 |
| `content` | `string` | 当前内容（受控时为 `content` prop，否则为内部 state）。 |
| `setContent` | `(value: string \| ((prev) => string)) => void` | 非受控场景下更新内容。受控时（传入 `content` prop）为 no-op。 |
| `effectiveContent` | `string` | 实际传给 QR 生成器的内容，优先级：`content` prop > 内部 state > `contentForQR`。 |
| `dimensions` | `{ width: number; height: number }` | 当前容器尺寸（来自 `dimensionsProp` 或默认 300×300）。 |
| `previewMode` | `'canvas' \| 'svg'` | 与入参 `previewMode` 一致，固定值。 |
| `canUseSvgPreview` | `boolean` | 恒为 `true`，保留用于兼容性。 |

#### 预览

| 方法 | 签名 | 说明 |
|------|------|------|
| `drawPreview` | `(canvas: HTMLCanvasElement) => Promise<void>` | 将 QR 绘制到指定 canvas。自动 DPR 缩放，双缓冲无闪烁，竞态安全。内容为空时立即返回。 |
| `renderPreviewToSvg` | `(container: HTMLElement) => Promise<void>` | 在指定容器内挂载 SVG 预览（清空后追加）。竞态安全。内容为空时立即返回。 |

#### 导出

| 方法 | 签名 | 说明 |
|------|------|------|
| `getRawData` | `(extension?: FileExtension, scale?: number) => Promise<Blob \| null>` | 获取 Blob。`extension` 默认 `'png'`；`scale` 默认 **2**（SVG 忽略 scale）。内容为空时返回 `null`。 |
| `download` | `(opts?: { name?: string; extension?: FileExtension; scale?: number }) => Promise<void>` | 触发浏览器下载。`name` 默认 `'qr'`，`extension` 默认 `'png'`，`scale` 默认 **2**（SVG 忽略）。 |
| `exportAsDataURL` | `(extension?: FileExtension, scale?: number) => Promise<string>` | 导出为 Data URL 字符串。`extension` 默认 `'png'`，`scale` 默认 **2**（SVG 忽略）。失败或内容空时返回 `''`。 |

> **scale 说明**：`scale=2` 时，300×300 容器导出为 600×600 px 图像。建议 `scale` 取 2（屏幕）或 3/4（印刷）。SVG 无损矢量，无需 scale。

---

### 完整示例

```tsx
import { useRef, useEffect, useState, useMemo } from 'react'
import { useBeautifyQRCode } from '@gulibs/beautify-qrcode-hooks'
import type { BeautificationSettings, FileExtension } from '@gulibs/beautify-qrcode-hooks'

function QREditor() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const svgRef    = useRef<HTMLDivElement>(null)
  const [mode, setMode]       = useState<'canvas' | 'svg'>('canvas')
  const [content, setContent] = useState('https://example.com')
  const [margin, setMargin]   = useState(10)

  // 受控模式：settings 随 margin 变化自动同步
  const settings = useMemo<BeautificationSettings>(() => ({
    qrCodeStyle: {
      foregroundColor: '#1e40af',
      errorCorrectionLevel: 'Q',
      margin,
      dotType: 'extra-rounded',
    },
  }), [margin])

  const { drawPreview, renderPreviewToSvg, download, exportAsDataURL } =
    useBeautifyQRCode({ content, dimensions: { width: 300, height: 300 }, settings })

  useEffect(() => {
    if (mode === 'canvas' && canvasRef.current) drawPreview(canvasRef.current)
  }, [mode, drawPreview])

  useEffect(() => {
    if (mode === 'svg' && svgRef.current) renderPreviewToSvg(svgRef.current)
  }, [mode, renderPreviewToSvg])

  return (
    <div>
      {mode === 'canvas'
        ? <canvas ref={canvasRef} style={{ width: 300, height: 300 }} />
        : <div ref={svgRef} style={{ width: 300, height: 300 }} />}

      <input value={margin} type="range" min={0} max={40}
        onChange={e => setMargin(Number(e.target.value))} />

      <button onClick={() => download({ name: 'qr', extension: 'png', scale: 3 })}>
        下载 3× PNG
      </button>
      <button onClick={() => download({ extension: 'svg' })}>
        下载 SVG
      </button>
    </div>
  )
}
```

---

## useBatchBeautify

批量生成与导出，每条数据独立配置。

### 入参 `UseBatchBeautifyOptions`

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `items` | `BatchBeautifyItem[]` | **必填** | 数据项数组，见下方 `BatchBeautifyItem` 说明。 |
| `dimensions` | `{ width: number; height: number }` | `{ width: 300, height: 300 }` | 每条的预览/导出容器尺寸。 |
| `format` | `FileExtension` | `'png'` | `getDataURL` / `exportAll` 的默认格式。 |
| `extension` | `ExtensionFunction` | — | 所有条共用的 SVG 扩展。 |
| `plugins` | `BeautifyQRCodePlugin[]` | — | 所有条共用的插件数组。 |
| `pluginRegistry` | `PluginRegistry \| null` | — | 外部 registry，优先于 `plugins`。 |
| `logoMap` | `Map<number, string>` | — | 按条索引指定 logo URL，覆盖 `item.initialSettings.logo.url`。 |
| `onError` | `QRCodeErrorCallback` | — | 出错时回调。 |

**`BatchBeautifyItem`**

| 属性 | 类型 | 说明 |
|------|------|------|
| `id` | `string \| number` | 唯一标识。 |
| `content` | `string` | 该条的 QR 内容。 |
| `title` | `string?` | 可选显示标题。 |
| `initialSettings` | `BeautificationSettings?` | 该条的样式配置；可被 `logoMap` 的 logo 覆盖。 |

---

### 返回值 `UseBatchBeautifyReturn`

#### 进度状态

| 属性 | 类型 | 说明 |
|------|------|------|
| `isExporting` | `boolean` | `exportAll` 执行期间为 `true`。 |
| `progress` | `number` | 0–1，`exportAll` 的完成比例。 |
| `completedCount` | `number` | `exportAll` 已完成的条数。 |

#### 方法

| 方法 | 签名 | 说明 |
|------|------|------|
| `getItemPreview` | `(index: number) => ItemPreviewHandle` | 获取指定条（按数组下标）的预览句柄。 |
| `exportAll` | `(scale?: number) => Promise<Blob[]>` | 批量导出所有条为 Blob 数组；`scale` 默认 **2**；格式由 `format` 决定。返回与 `items` 等长的数组，失败条为空 Blob。 |

**`ItemPreviewHandle`**

| 方法 | 签名 | 说明 |
|------|------|------|
| `drawPreview` | `(canvas: HTMLCanvasElement) => Promise<void>` | 将该条绘制到指定 canvas；双缓冲无闪烁，自动 DPR。 |
| `getDataURL` | `(ext?: FileExtension) => Promise<string>` | 该条以指定格式（默认 `format`）导出为 Data URL。 |
| `renderToSvg` | `(container: HTMLElement) => Promise<void>` | 将该条渲染为 SVG 并挂载到容器；QR 元素通过 `position: absolute` 定位到正确区域。 |

---

### 完整示例

```tsx
import { useRef, useEffect } from 'react'
import { useBatchBeautify } from '@gulibs/beautify-qrcode-hooks'

const items = [
  { id: 1, content: 'https://a.com', title: 'A' },
  { id: 2, content: 'https://b.com', title: 'B' },
]

function BatchQR() {
  const { getItemPreview, exportAll, isExporting, progress } =
    useBatchBeautify({ items, dimensions: { width: 200, height: 200 } })

  return (
    <div>
      {items.map((item, i) => {
        const canvasRef = useRef<HTMLCanvasElement>(null)
        const handle = getItemPreview(i)
        useEffect(() => {
          if (canvasRef.current) handle.drawPreview(canvasRef.current)
        }, [handle])
        return (
          <div key={item.id}>
            <canvas ref={canvasRef} style={{ width: 200, height: 200 }} />
            <div>{item.title}</div>
          </div>
        )
      })}

      <button onClick={() => exportAll(3)} disabled={isExporting}>
        {isExporting ? `导出中 ${Math.round(progress * 100)}%` : '批量导出 3×'}
      </button>
    </div>
  )
}
```

---

## computePreviewRect

```ts
function computePreviewRect(
  width: number,
  height: number,
  settings?: BeautificationSettings
): PreviewRect
```

计算 QR 码在容器内的绘制区域，保证 QR 不越界，文字带不覆盖码体。

**返回 `PreviewRect`**

| 属性 | 说明 |
|------|------|
| `qrSize` | QR 区域的边长（像素），取 `min(width, qrAreaHeight)`。 |
| `qrX` | QR 区域左上角 X（水平居中：`(width - qrSize) / 2`）。 |
| `qrY` | QR 区域左上角 Y（扣除 above 文字带高度）。 |
| `width` | 透传容器宽度。 |
| `height` | 透传容器高度。 |

**文字带逻辑**：

- 若 `settings.textOverlays` 含 `slot: 'above'` 且 `content` 非空，顶部预留 `fontSize + 2×padding + textQrGap` 高度，`qrY` 偏移到该带之后。
- 若含 `slot: 'below'` 且 `content` 非空，底部预留相同高度，`qrSize` 相应缩小。
- `slot: 'middle'`、`position` 定位的项不影响布局。

---

## PluginRegistry

```ts
class PluginRegistry {
  register(plugin: BeautifyQRCodePlugin): void
  unregister(pluginId: string): void
  getPlugins(): BeautifyQRCodePlugin[]
  applyTransformOptions(options: QROptions, settings: BeautificationSettings): Options
}
```

| 方法 | 说明 |
|------|------|
| `register(plugin)` | 注册插件；同一 `id` 重复注册抛 `Error`。 |
| `unregister(pluginId)` | 按 `id` 移除插件，不存在时静默。 |
| `getPlugins()` | 返回当前所有已注册插件的数组（按注册顺序）。 |
| `applyTransformOptions(options, settings)` | 依次对每个插件调用 `transformOptions`，链式传递，返回最终 options。 |

**使用时机**：

- 需要**运行时动态**增删插件时用 `pluginRegistry`；
- 仅使用固定插件列表时，直接传 `plugins` 即可（Hook 内部自动创建 registry）；
- 两者同时传入时，`pluginRegistry` 优先，`plugins` 被忽略。

---

## PreviewArea

```tsx
<PreviewArea
  ref={ref}
  width={300}
  height={300}
  mode="canvas"      // 'canvas' | 'svg'，仅写入 data-preview-mode，不影响行为
  className="..."
  style={{ ... }}
/>
```

Ref 转发的 `<div>`，定位为 `position: relative; overflow: hidden`，作为预览挂载点。**不内置绘制逻辑**，需业务侧通过 ref 调用 `drawPreview(canvas)` 或 `renderPreviewToSvg(ref.current)`。

---

## buildStylingOptions

```ts
function buildStylingOptions(
  content: string,
  settings: BeautificationSettings,
  width: number,
  height: number,
  type?: 'canvas' | 'svg'   // 默认 'canvas'
): Partial<Options>          // qr-code-styling 的 Options
```

将 `content` + `BeautificationSettings` + 尺寸转为 `qr-code-styling` 的原始 `Options`，**不**执行插件 `transformOptions`。

与 `getOptionsForQR` 的区别：

| | `buildStylingOptions` | `getOptionsForQR` |
|---|---|---|
| 插件 transformOptions | ❌ 不执行 | ✅ 链式执行 |
| 强制 width/height | ❌ 取传入值 | ✅ 强制为 qrSize |
| 返回类型 | `Partial<Options>` | `Options` |

适用于需要**直接操作底层 options** 或**自定义渲染流程**的场景。

---

## Core API（非 React）

以下函数不依赖 React，可在组件外、Worker、或服务端（仅浏览器 API 可用时）使用。

### getOptionsForQR

```ts
function getOptionsForQR(
  content: string,
  settings: BeautificationSettings,
  qrSize: number,
  type: 'canvas' | 'svg',
  pluginRegistry?: PluginRegistry | null
): Options
```

构建完整的 `qr-code-styling` Options：

1. 调用 `buildStylingOptions` 生成基础 options；
2. 若传入 `pluginRegistry`，依次执行各插件的 `transformOptions`；
3. **强制** `width = height = qrSize`（防止插件修改尺寸导致越界）。

---

### createQRInstance

```ts
function createQRInstance(
  content: string,
  settings: BeautificationSettings,
  qrSize: number,
  type: 'canvas' | 'svg',
  extensions: ExtensionFunction[],
  pluginRegistry?: PluginRegistry | null
): QRCodeStyling
```

创建并返回已应用 `extensions`（SVG 扩展函数）的 `QRCodeStyling` 实例，可调用 `.append(container)` 挂载到 DOM，或 `.getRawData(extension)` 取 Blob。

---

### getQRRawData

```ts
async function getQRRawData(
  content: string,
  settings: BeautificationSettings,
  qrSize: number,
  extension: 'png' | 'jpeg' | 'webp' | 'svg',
  extensions: ExtensionFunction[],
  pluginRegistry?: PluginRegistry | null
): Promise<Blob | null>
```

直接生成指定格式的 Blob（不经过 Hook 渲染管线，不含插件 beforeRender/afterRender）。适用于服务端批处理、Worker 或不需要 canvas 插件的场景。

---

### getQRRawDataForCanvas

```ts
async function getQRRawDataForCanvas(
  content: string,
  settings: BeautificationSettings,
  cssQrSize: number,
  physicalScale: number,
  extensions: ExtensionFunction[],
  pluginRegistry?: PluginRegistry | null
): Promise<Blob | null>
```

专为高 DPR Canvas 渲染设计的 PNG 生成器，解决 `getQRRawData` 直接放大时像素比例失真的问题：

1. 先在 **CSS 像素尺寸**（`cssQrSize`）下解析所有 options（含插件 transformOptions），确保插件始终看到与 SVG 模式一致的 CSS 像素值；
2. 再将**像素型字段**（`margin`、`imageOptions.margin`）按 `physicalScale` 等比放大，`width/height` 设为 `cssQrSize × physicalScale`；
3. 比例型字段（`imageSize`、`backgroundRound` 等）保持不变。

`physicalScale <= 1` 时退化为 `getQRRawData`。

---

### downloadURI

```ts
function downloadURI(uri: string, name: string): void
```

通过创建隐藏 `<a download>` 触发浏览器下载。`uri` 可为 Data URL 或 Blob URL；`name` 为文件名（含扩展名）。

---

## 内置插件

### backgroundImagePlugin

在 QR 码下方渲染自定义背景图。

**配置**：通过 `settings.pluginOptions['background-image']` 传入 `BackgroundImageOptions`：

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `imageUrl` | `string` | — | Data URL 或 Blob URL（必须是同域或 Data URL） |
| `opacity` | `number` | `1` | 不透明度 0–1 |
| `position` | `'fill' \| 'fit' \| 'stretch'` | `'fill'` | `fill`=裁切填满；`fit`=等比缩放居中留空；`stretch`=拉伸不保比 |

**工作机制**：

- `transformOptions`：当 `imageUrl` 存在时，自动将 QR 背景色设为 `transparent`。
- `beforeRender`（Canvas）：异步加载图片（带内存缓存），在绘制 QR 前将背景图 draw 到 canvas，自动处理 DPR 坐标（从 `canvas.dataset.cssWidth/Height` 读取 CSS 尺寸）。
- `svgExtension`（SVG）：在 SVG 根节点最前面插入 `<image>` 元素，`preserveAspectRatio` 按 `position` 映射为对应值。

```tsx
import { useBeautifyQRCode, backgroundImagePlugin } from '@gulibs/beautify-qrcode-hooks'
import type { BackgroundImageOptions, BeautificationSettings } from '@gulibs/beautify-qrcode-hooks'

const settings = useMemo<BeautificationSettings>(() => ({
  qrCodeStyle: { backgroundColor: 'transparent', margin: 8 },
  pluginOptions: {
    'background-image': {
      imageUrl: myBgUrl,
      opacity: 0.7,
      position: 'fill',
    } satisfies BackgroundImageOptions,
  },
}), [myBgUrl])

const { drawPreview } = useBeautifyQRCode({
  content: 'https://example.com',
  dimensions: { width: 300, height: 300 },
  plugins: [backgroundImagePlugin],
  settings,
})
```

**`loadBackgroundImage(url)`**：独立导出的工具函数，返回 `Promise<HTMLImageElement>`，带 URL→HTMLImageElement 内存缓存，可用于预加载。

---

### textOverlayPlugin

在 QR 码上方/下方/指定位置渲染文字，Canvas 与 SVG 双模式同步。

**配置**：通过 `settings.textOverlays: TextOverlayItem[]` 传入，无需 `pluginOptions`。

**`TextOverlayItem`**

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `id` | `string` | **必填** | 唯一标识。 |
| `content` | `string` | **必填** | 文字内容，空字符串不渲染。 |
| `slot` | `'above' \| 'middle' \| 'below'` | — | 预设槽位。`above`/`below` 配合 `computePreviewRect` 预留空间；`middle` 居中于 QR 区域。**slot 优先于 position**。 |
| `position` | `{ x: number; y: number }` | `{ x: 0.5, y: 0.9 }` | 归一化坐标（0=左/上，1=右/下），无 slot 时生效，映射为 `x × width, y × height`。 |
| `positionOffset` | `{ x: number; y: number }` | — | 在 slot/position 基础上的附加归一化偏移（目前保留字段，插件内部暂未应用）。 |
| `style` | `TextOverlayStyle` | — | 文字样式，见下表。 |

**`TextOverlayStyle`**

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `fontSize` | `number` | `16` | 字号（px） |
| `fontFamily` | `string` | `'Arial'` | 字体族 |
| `color` | `string` | `'#000000'` | 文字颜色 |
| `fontWeight` | `string` | `'normal'` | 字重，如 `'bold'`、`'600'` |
| `alignment` | `'left' \| 'center' \| 'right'` | `'center'` | 水平对齐 |
| `strokeWidth` | `number` | — | 描边宽度（px）；设置后同时绘制描边 |
| `strokeColor` | `string` | `'#ffffff'` | 描边颜色 |
| `shadowOffsetX` | `number` | `0` | 阴影 X 偏移 |
| `shadowOffsetY` | `number` | `0` | 阴影 Y 偏移 |
| `shadowBlur` | `number` | — | 阴影模糊半径；`> 0` 时启用阴影 |
| `shadowColor` | `string` | `'rgba(0,0,0,0.5)'` | 阴影颜色 |
| `backgroundColor` | `string` | — | 文字背景填充色（绘制在文字矩形后面） |
| `borderWidth` | `number` | — | 文字背景边框宽度；需同时设置 `backgroundColor` 或 `borderColor` |
| `borderColor` | `string` | `'#000000'` | 文字背景边框颜色 |

**工作机制**：

- `afterRender`（Canvas）：遍历 `settings.textOverlays`，按 slot/position 计算坐标，逐条绘制文字（含背景、描边、阴影）。从 `canvas.dataset.cssWidth/Height` 读取 CSS 尺寸，DPR 无关。
- `svgExtension`（SVG）：为每条文字生成对应的 SVG `<text>` 元素，样式与 Canvas 渲染保持视觉一致。

```tsx
import { useBeautifyQRCode, textOverlayPlugin } from '@gulibs/beautify-qrcode-hooks'
import type { BeautificationSettings } from '@gulibs/beautify-qrcode-hooks'

const settings = useMemo<BeautificationSettings>(() => ({
  qrCodeStyle: { margin: 8 },
  textOverlays: [
    {
      id: 'header',
      content: '扫码关注',
      slot: 'above',
      style: { fontSize: 16, color: '#1e40af', fontWeight: 'bold' },
    },
    {
      id: 'footer',
      content: 'example.com',
      slot: 'below',
      style: { fontSize: 13, color: '#6b7280' },
    },
  ],
}), [])

const { drawPreview, renderPreviewToSvg } = useBeautifyQRCode({
  content: 'https://example.com',
  dimensions: { width: 300, height: 360 },  // height > width 为文字带预留空间
  plugins: [textOverlayPlugin],
  settings,
})
```

---

### 组合多个插件

多个插件按数组顺序执行，彼此独立：

```tsx
const { drawPreview } = useBeautifyQRCode({
  content: 'https://example.com',
  dimensions: { width: 300, height: 360 },
  plugins: [backgroundImagePlugin, textOverlayPlugin],
  settings: useMemo(() => ({
    qrCodeStyle: { backgroundColor: 'transparent', margin: 8 },
    pluginOptions: {
      'background-image': { imageUrl: bgUrl, opacity: 0.6, position: 'fill' },
    },
    textOverlays: [
      { id: 'footer', content: 'Scan Me', slot: 'below', style: { fontSize: 14 } },
    ],
  }), [bgUrl]),
})
```

---

## 插件开发

插件只负责**渲染逻辑**（修改 QR options、修改 SVG、在 canvas 上绘制）；**UI 与状态由业务侧管理**，通过 `settings.pluginOptions[id]` 与插件通信。

### BeautifyQRCodePlugin 接口

```ts
interface BeautifyQRCodePlugin {
  id: string
  transformOptions?: (options: QROptions, settings: BeautificationSettings) => QROptions
  svgExtension?: ExtensionFunction | PluginSvgExtension
  svgExtensionOnlyForSvgOutput?: boolean
  beforeRender?: (
    ctx: CanvasRenderingContext2D,
    settings: BeautificationSettings,
    canvas: HTMLCanvasElement
  ) => void | Promise<void>
  afterRender?: (
    ctx: CanvasRenderingContext2D,
    settings: BeautificationSettings,
    canvas: HTMLCanvasElement
  ) => void | Promise<void>
}
```

| 字段 | 说明 |
|------|------|
| `id` | 唯一字符串，作为 `settings.pluginOptions[id]` 的键，以及 `PluginRegistry` 的索引。 |
| `transformOptions(options, settings)` | 在生成 QR 前修改底层 `qr-code-styling` Options（如增加 margin、修改颜色）。**链式执行**，必须返回新 options 对象（不可 mutate）。不论 Canvas 还是 SVG 模式均会调用。 |
| `svgExtension(svg, options[, settings])` | 仅在有 SVG 输出时调用（SVG 预览、SVG 导出；以及 `svgExtensionOnlyForSvgOutput` 为 `false` 时的栅格导出）。操作 SVG 根节点，追加/修改 SVG 元素。`options.width/height` 为**外层容器**尺寸（见坐标系一致性章节）。 |
| `svgExtensionOnlyForSvgOutput` | 默认 `false`。设为 `true` 后，`svgExtension` 仅在 SVG 输出时调用，栅格（PNG/JPEG/WebP）导出时跳过。通常与同时实现 `afterRender` 的插件配合，避免同一效果被渲染两次。 |
| `beforeRender(ctx, settings, canvas)` | Canvas 绘制前调用（在生成 QR 之前）。**支持 `async`**，Hook 会 `await`。典型用途：绘制背景图、渐变背景。 |
| `afterRender(ctx, settings, canvas)` | Canvas 绘制后调用（在 QR 绘制到 canvas 之后）。**支持 `async`**，Hook 会 `await`。典型用途：绘制文字、水印、边框装饰。 |

### 执行顺序

```
Canvas 预览 / 栅格导出（PNG, JPEG, WebP）：
  transformOptions（若用 registry）
  → beforeRender × N（按 plugins 数组顺序，await async）
  → 生成 QR 图像（getQRRawDataForCanvas）
  → 将 QR drawImage 到 canvas
  → afterRender × N（按 plugins 数组顺序，await async）

SVG 预览 / SVG 导出：
  transformOptions（若用 registry）
  → 生成 QR SVG blob
  → svgExtension × N（按 plugins 数组顺序；skipped if svgExtensionOnlyForSvgOutput && 当前是栅格路径）
```

### Canvas DPR 注意事项

**在 `beforeRender` / `afterRender` 中**，canvas buffer 尺寸为物理像素（CSS 尺寸 × DPR），但 `ctx` 已 `scale(dpr, dpr)`，所以**绘制坐标使用 CSS 像素**即可：

```ts
// ✅ 正确：从 dataset 读取 CSS 尺寸
const w = Number(canvas.dataset.cssWidth)  || canvas.width
const h = Number(canvas.dataset.cssHeight) || canvas.height
ctx.fillText('hello', w / 2, h - 10)  // CSS 像素坐标

// ❌ 错误：直接用 canvas.width 是物理像素，DPR=2 时坐标翻倍偏移
ctx.fillText('hello', canvas.width / 2, canvas.height - 10)
```

### 插件状态读写

```ts
// 业务侧（组件）写入状态
updateSettings(prev => ({
  ...prev,
  pluginOptions: {
    ...prev.pluginOptions,
    [myPluginId]: { text: '© 2026', color: '#999' },
  },
}))

// 插件内读取状态
interface MyOptions { text?: string; color?: string }

const plugin: BeautifyQRCodePlugin = {
  id: myPluginId,
  afterRender(ctx, settings, canvas) {
    const opts = settings.pluginOptions?.[myPluginId] as MyOptions | undefined
    if (!opts?.text) return
    // ...绘制
  },
}
```

### 自定义插件完整示例

```tsx
import type { BeautifyQRCodePlugin } from '@gulibs/beautify-qrcode-hooks'

interface WatermarkOptions {
  text?: string
  color?: string
  fontSize?: number
}

/**
 * 水印插件：在 canvas 右下角 afterRender 绘制，SVG 模式通过 svgExtension 添加同等效果。
 * svgExtensionOnlyForSvgOutput: true — 避免 PNG 导出时 svgExtension 与 afterRender 重复绘制。
 */
export const watermarkPlugin: BeautifyQRCodePlugin = {
  id: 'watermark',
  svgExtensionOnlyForSvgOutput: true,

  afterRender(ctx, settings, canvas) {
    const opts = settings.pluginOptions?.['watermark'] as WatermarkOptions | undefined
    if (!opts?.text) return
    const w = Number(canvas.dataset.cssWidth)  || canvas.width
    const h = Number(canvas.dataset.cssHeight) || canvas.height
    const fs = opts.fontSize ?? 12
    ctx.save()
    ctx.font = `${fs}px sans-serif`
    ctx.fillStyle = opts.color ?? 'rgba(0,0,0,0.25)'
    ctx.textAlign = 'right'
    ctx.textBaseline = 'bottom'
    ctx.fillText(opts.text, w - 6, h - 6)
    ctx.restore()
  },

  svgExtension(svg, _options, settings) {
    const opts = settings?.pluginOptions?.['watermark'] as WatermarkOptions | undefined
    if (!opts?.text) return
    const w = Number(svg.getAttribute('width'))  || 300
    const h = Number(svg.getAttribute('height')) || 300
    const fs = opts.fontSize ?? 12
    const ns = 'http://www.w3.org/2000/svg'
    const el = document.createElementNS(ns, 'text')
    el.setAttribute('x', String(w - 6))
    el.setAttribute('y', String(h - 6))
    el.setAttribute('text-anchor', 'end')
    el.setAttribute('dominant-baseline', 'auto')
    el.setAttribute('font-size', String(fs))
    el.setAttribute('font-family', 'sans-serif')
    el.setAttribute('fill', opts.color ?? 'rgba(0,0,0,0.25)')
    el.textContent = opts.text
    svg.appendChild(el)
  },
}

// 使用方式：
// plugins={[watermarkPlugin]}
// settings={{ pluginOptions: { watermark: { text: '© 2026', color: '#888' } } }}
```

---

## Extension vs Plugin vs PluginRegistry 对比

| | `extension` | `plugins` | `pluginRegistry` |
|---|---|---|---|
| 传参位置 | `extension: fn` | `plugins: [...]` | `pluginRegistry: registry` |
| 支持 transformOptions | ❌ | ✅ | ✅ |
| 支持 svgExtension | ✅（即为 `extension` 本身） | ✅ | ✅ |
| 支持 beforeRender / afterRender | ❌ | ✅ | ✅ |
| 支持运行时增删 | ❌ | ❌（重传数组） | ✅ `register`/`unregister` |
| 接收 settings 参数 | ❌ | ✅ | ✅ |
| 适用场景 | 简单 SVG 装饰（边框、水印），无状态 | 有配置的功能插件，静态列表 | 动态插件管理，运行时按需启用 |

---

## BeautificationSettings 完整类型参考

### BeautificationSettings

```ts
interface BeautificationSettings {
  qrCodeStyle?: QRCodeStyleSettings
  logo?: LogoSettings
  textOverlays?: TextOverlayItem[]
  pluginOptions?: Record<string, unknown>  // 插件状态，按 plugin.id 键
}
```

---

### QRCodeStyleSettings

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `foregroundColor` | `string` | `'#000000'` | 单色模式下码点颜色（`dotsColorType: 'single'` 时生效） |
| `backgroundColor` | `string` | `'#FFFFFF'` | 单色背景色（`backgroundColorType: 'single'` 时生效） |
| `errorCorrectionLevel` | `'L' \| 'M' \| 'Q' \| 'H'` | `'H'` | 纠错级别；有 logo 时建议 `'Q'` 或 `'H'` |
| `margin` | `number` | `0` | 外边距（px，CSS 尺寸空间），QR 图内部的静区宽度 |
| `version` | `number` | 自动 | QR 版本 1–40（点阵密度）；若内容需要更高版本则自动升级 |
| `mode` | `'Numeric' \| 'Alphanumeric' \| 'Byte' \| 'Kanji'` | 自动 | 编码模式；内容不符合该模式时自动降级 |
| `dotType` | `DotType` | `'square'` | 码点形状：`square` `dots` `rounded` `classy` `classy-rounded` `extra-rounded` |
| `cornerSquareType` | `CornerSquareType` | `'square'` | 角框（定位图案外框）形状：`square` `dot` `extra-rounded` `rounded` `dots` `classy` `classy-rounded` |
| `cornerDotType` | `CornerDotType` | `'square'` | 角点（定位图案内点）形状：`square` `dot` `rounded` `dots` `classy` `classy-rounded` `extra-rounded` |
| `shape` | `'square' \| 'circle'` | `'square'` | QR 整体外形（`circle` 将码裁切为圆形） |
| `dotsRoundSize` | `boolean` | `true` | 是否对码点启用圆角对齐优化 |
| `backgroundRound` | `number` | — | 背景矩形圆角半径（0–1 比例） |
| `dotsColorType` | `'single' \| 'gradient'` | `'single'` | 码点着色方式；`'gradient'` 时读 `foregroundGradient` |
| `foregroundGradient` | `ColorGradient` | — | 码点渐变配置（`dotsColorType: 'gradient'` 时生效） |
| `cornerSquareColorType` | `'single' \| 'gradient'` | `'single'` | 角框着色方式 |
| `cornerSquareColor` | `string` | 同 `foregroundColor` | 角框单色颜色 |
| `cornerSquareGradient` | `ColorGradient` | — | 角框渐变 |
| `cornerDotColorType` | `'single' \| 'gradient'` | `'single'` | 角点着色方式 |
| `cornerDotColor` | `string` | 同 `foregroundColor` | 角点单色颜色 |
| `cornerDotGradient` | `ColorGradient` | — | 角点渐变 |
| `backgroundColorType` | `'single' \| 'gradient'` | `'single'` | 背景着色方式 |
| `backgroundGradient` | `ColorGradient` | — | 背景渐变 |

**`ColorGradient`**

```ts
interface ColorGradient {
  type: 'linear' | 'radial'
  rotation?: number                                // 旋转角度（度，非弧度）
  colorStops: Array<{ offset: number; color: string }>  // offset 0–1
}
```

---

### LogoSettings

| 属性 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `url` | `string` | **必填** | logo 图片 URL；支持 Data URL（`data:`）和 HTTP URL |
| `size` | `number` | `40` | logo 占 QR 尺寸的百分比（1–100） |
| `margin` | `number` | `0` | logo 周围的清除区域宽度（px，输出图像空间） |
| `hideBackgroundDots` | `boolean` | `true` | 是否隐藏 logo 区域内的码点 |
| `crossOrigin` | `'anonymous' \| 'use-credentials'` | 自动 | 跨域图片的 CORS 策略；HTTP URL 自动设为 `'anonymous'` |
| `saveAsBlob` | `boolean` | 自动 | Data URL 图片是否先转为 Blob 再加载（Data URL 时自动为 `true`） |

---

### 其他类型

| 类型 | 说明 |
|------|------|
| `FileExtension` | `'png' \| 'jpeg' \| 'webp' \| 'svg'` |
| `RasterExportFormat` | `'png' \| 'jpeg' \| 'webp'`（不含 svg） |
| `ExtensionFunction` | `(svg: SVGElement, options: QROptions) => void` |
| `PluginSvgExtension` | `(svg: SVGElement, options: QROptions, settings?: BeautificationSettings) => void` |
| `PreviewRect` | `{ qrSize, qrX, qrY, width, height }` |
| `QRCodeErrorContext` | `{ source: 'canvasPreview' \| 'svgPreview' \| 'export' \| 'batch' }` |
| `QRCodeErrorCallback` | `(error: unknown, context: QRCodeErrorContext) => void` |
| `QRCodeStylingOptions` | `qr-code-styling` 的原始 `Options` 类型（re-export） |
| `BatchDisplayMode` | `'grid' \| 'tabs'`（保留，未在 Hook 内使用） |

---

## 完整导出列表

```ts
// Hooks
export { useBeautifyQRCode } from '...'
export type { UseBeautifyQRCodeOptions, UseBeautifyQRCodeReturn } from '...'

export { useBatchBeautify } from '...'
export type { UseBatchBeautifyOptions, UseBatchBeautifyReturn, ItemPreviewHandle } from '...'

// 工具函数
export { computePreviewRect } from '...'
export { PluginRegistry } from '...'
export { buildStylingOptions } from '...'
export { PreviewArea } from '...'
export type { PreviewAreaProps } from '...'

// 内置插件
export { backgroundImagePlugin, loadBackgroundImage } from '...'
export { textOverlayPlugin } from '...'

// Core（非 React）
export { getOptionsForQR, createQRInstance, getQRRawData, downloadURI } from '...'

// 类型
export type {
  BeautificationSettings, QRCodeStyleSettings, LogoSettings,
  TextOverlayItem, TextOverlayStyle, BackgroundImageOptions,
  BeautifyQRCodePlugin, ExtensionFunction, PluginSvgExtension,
  BatchBeautifyItem, BatchDisplayMode, FileExtension, RasterExportFormat,
  PreviewRect, QRCodeErrorCallback, QRCodeErrorContext,
  QRCodeStylingOptions, ColorGradient, ColorGradientType,
  DotType, CornerSquareType, CornerDotType,
} from '...'
```

---

## 与 qr-code-styling 官网能力对照

| 区域 | 选项 | 支持 | 映射字段 |
|------|------|------|------|
| 主选项 | Data | ✅ | `content` |
| | Width / Height | ✅ | `dimensions` |
| | Margin（静区） | ✅ | `qrCodeStyle.margin` |
| | Image（Logo） | ✅ | `logo.url` |
| Dots | 样式 | ✅ | `qrCodeStyle.dotType` |
| | 单色 | ✅ | `qrCodeStyle.foregroundColor` |
| | 渐变（含旋转） | ✅ | `qrCodeStyle.foregroundGradient` |
| Corners Square | 样式 | ✅ | `qrCodeStyle.cornerSquareType` |
| | 单色/渐变 | ✅ | `qrCodeStyle.cornerSquareColor/Gradient` |
| Corners Dot | 样式 | ✅ | `qrCodeStyle.cornerDotType` |
| | 单色/渐变 | ✅ | `qrCodeStyle.cornerDotColor/Gradient` |
| Background | 单色/渐变 | ✅ | `qrCodeStyle.backgroundColor/Gradient` |
| | 圆角 | ✅ | `qrCodeStyle.backgroundRound` |
| Background Image | 贴图 / 透明度 / 填充模式 | ✅ | `backgroundImagePlugin` + `BackgroundImageOptions` |
| Text Overlay | 自由位置/上下文字 | ✅ | `textOverlayPlugin` + `TextOverlayItem[]` |
| Image Options | Hide background dots | ✅ | `logo.hideBackgroundDots` |
| | Logo Size | ✅ | `logo.size`（百分比） |
| | Logo Margin | ✅ | `logo.margin` |
| QR Options | Type Number（版本） | ✅ | `qrCodeStyle.version` |
| | Error Correction Level | ✅ | `qrCodeStyle.errorCorrectionLevel` |
| | Mode | ✅ | `qrCodeStyle.mode` |
| | Shape（圆形 QR） | ✅ | `qrCodeStyle.shape` |
| Export | PNG / JPEG / WebP / SVG | ✅ | `download` / `getRawData` |
| | 自定义导出分辨率 | ✅ | `scale` 参数（默认 2×） |
| | 导出为 Data URL | ✅ | `exportAsDataURL` |
| | 配置序列化为 JSON | ✅ | `JSON.stringify(settings)` |

---

## 脚本

```bash
pnpm dev    # 启动示例应用（Vite dev server，含完整功能演示）
pnpm build  # 构建库到 dist/（ES + CJS + 类型声明）
pnpm lint   # ESLint 检查
```