# 自定义节点导出为 Word、PDF 与 OFD

共用原生内容转换和图片渲染，处理模板、资源失败、取消与 Word 元数据恢复。

导出适配器读取 Markdown 源码快照，与你的 Vue/React 节点视图分开。节点的屏幕外观不会自动
进入文件；需要明确把它转换为可打印内容。Word、PDF 与 OFD 可以复用同一份 `extensions`。
模板的选择方式见 [MD Editor](/guide/md-editor#导出模板)。

## Quire Render 与多格式导出

**Quire Render** 是 QuireKit 的统一渲染引擎。Word、PDF 与 OFD 先复用节点的语义转换和
模板；Word 将结果编译为可编辑 DOCX，PDF/OFD 则通过 Classic 完成分页，再把逐页内容
交给 Quire Render 的对应后端。

| 名称 | 职责 |
| --- | --- |
| `qkView` | 声明式组合组件，处理组件内部的局部布局 |
| Quire Display List | 用 `QuireDisplayList` 表达文字、路径、图片、裁剪等绘制指令 |
| Render Backend | 把绘制指令输出到 Canvas、SVG、PDF 或 OFD |

文档分页由专门的排版引擎负责，`qkView` 是生成绘制指令的一种入口。当前 Markdown
导出接入点仍是下面的原生内容或图片转换；Vue/DOM 视图、`qkView` 交互控件不会自动转换为
Word 或固定版式文件。各后端的字体、资源和损失报告也需要分别处理。

## 选择输出方式

| 需求 | 转换器输出 | Word | PDF / OFD |
| --- | --- | --- | --- |
| 提示块、业务卡片、可阅读的业务字段 | 原生段落、列表、表格、底纹和边框 | 可编辑文字 | 可选择文字，使用 Classic 分页 |
| 图表或复杂图形 | 宿主生成的 PNG/JPEG | 图片，可附恢复元数据 | 图片，保持比例并限制宽高 |
| 下次导入恢复业务数据 | 带版本的 Custom XML 元数据和可见内容 | 校验通过后恢复 | 不保存这套恢复数据 |
| 自定义 PDF/OFD 矢量绘制或分页算法 | 当前没有专用公开接口 | — | 需要另行扩展共享排版能力 |

PDF/OFD 消费现有 Classic 能识别的可见 LitAST；它不会执行 DOCX 的 package pass，也不加载
任意第三方 Classic schema。只写私有 XML 或返回一个未知 LitAST 类型，不能让 PDF/OFD 显示节点。

## 转换生命周期

1. `parse(source, next)`：仅当需要新 Markdown 语法时扩展解析。已有代码围栏可直接复用。
2. `prepare(root, context)`：校验业务数据、获取资源，把匹配节点投影为临时节点。异步工作在此完成。
3. `addDocx()`：返回与 Classic 相同的 `docx.codec.node()` 贡献。`convertChildren` 复用已有转换，
   `resolveService(markdownExportTemplateService)` 读取本次已解析模板。
4. 公共呈现阶段约束表格、图片和页边距；Word 编译为 DOCX，PDF/OFD 转入临时 Classic 文档并分页编码。

每个 `modelType` 只有一个转换所有者。不要直接认领所有 `code` 节点后丢弃不认识的语言。
下面先把验证成功的 `approval-card` 围栏改成独立的 `exampleApprovalCard`，其他代码继续使用默认转换。

## 原生业务卡片

这是独立的导出接入示例，没有给编辑器新增卡片视图或插入菜单。业务数据保存在普通围栏中：

````markdown
```approval-card
{"title":"Approval card","paragraphs":["Review release scope.","Confirm rollback plan."]}
```
````

完整实现使用公开包入口。TypeScript 消费者需要 `@types/mdast`；通过 `declare module 'mdast'`
声明临时节点。卡片复用模板的 `containers.note` 外观，单元格内距使用 Word 的 twips，
边线宽度使用八分之一磅；正文样式、字体和分页仍由公共导出链决定。

### export-nodes/native.ts

```ts
import { docx } from '@quirekit/lit-docx';
import type { Nodes } from 'mdast';
import { markdownExportTemplateService, type MarkdownDocxExtension } from '@quirekit/markdown-docx';
import { defaultExportTemplate } from '@quirekit/export-template';
import { approvalCardMarkdown, readApprovalCard } from './data';
import type { ApprovalCardNode } from './types';

const tag = 'example:approval-card';

/** @returns A standalone converter shared by Word and PDF; no editor or browser required. */
export function createApprovalCardExtension(): MarkdownDocxExtension {
  return {
    name: 'example.approval-card',
    prepare(root, context) {
      // Claim only validated fences. Invalid input remains ordinary code, with
      // a loss report; unrelated code and the caller's source are unchanged.
      /** @param node - Source subtree. @returns A fresh export-only projection. */
      function visit(node: Nodes): Nodes {
        if (node.type === 'code' && node.lang === 'approval-card') {
          try {
            if (node.value.length > 24_000) throw new Error('Card source exceeds its budget.');
            const data = readApprovalCard(JSON.parse(node.value));
            const card: ApprovalCardNode = { type: 'exampleApprovalCard', card: data, position: node.position,
              children: data.paragraphs.map(text => ({ type: 'paragraph', children: [{ type: 'text', value: text }] })) };
            return card;
          } catch {
            context.report({ code: 'approval-card-invalid', path: `/source/${node.position?.start.offset ?? 0}`,
              severity: 'warning', disposition: 'approximated', message: 'Invalid card data was retained as source code.' });
            return node;
          }
        }
        return 'children' in node ? { ...node, children: node.children.map(visit) } as Nodes : node;
      }
      return { ...root, children: root.children.map(node => visit(node) as typeof node) };
    },
    addDocx: () => docx.codec.node<Nodes, never>({
      id: 'example.approval-card', modelType: 'exampleApprovalCard',
      export: { convert: ({ node, docx: api, convertChildren, resolveService }) => {
        if (node.type !== 'exampleApprovalCard') throw new Error('Unexpected card node.');
        // Use the resolved template, then let the common projector constrain
        // the table width and Classic paginate it. Never set a fixed row height.
        const style = (resolveService(markdownExportTemplateService) ?? defaultExportTemplate.style).containers.note;
        const border = { val: style.border ? 'single' : 'nil', color: style.border?.color ?? 'auto',
          sz: Math.round((style.border?.width ?? 0) * 8), space: 0 };
        const padding = Math.round(style.padding * 20); // Template pt → native cell twips.
        const children = [api.blocks.paragraph({ properties: { keepNext: true },
          children: [api.inline.text(node.card.title, { marks: [api.marks.bold()] })] }), ...convertChildren(node.children)];
        const styled = children.map(child => ({ ...child, attrs: { ...child.attrs, styleId: 'QuireKitContainer_note' } }));
        const cell = api.tables.cell({ children: styled, width: { value: 5000, type: 'pct' },
          shading: { val: style.fill ? 'clear' : 'nil', fill: style.fill ?? 'auto' },
          borders: { top: border, bottom: border, left: border, right: border } });
        const table = api.tables.table({ width: { value: 5000, type: 'pct' }, rows: [api.tables.row({ cells: [
          { ...cell, attrs: { ...cell.attrs, cellMargins: { top: padding, bottom: padding, left: padding, right: padding } } },
        ] })] });
        return [api.roundTrip.taggedBlock({ tag, alias: node.card.title, children: [table],
          metadata: { version: 1, data: { title: node.card.title, paragraphs: [...node.card.paragraphs] } } })];
      } },
      import: { from: docx.roundTrip.matchTag(tag), creates: ['docxSource', 'paragraph', 'heading', 'list', 'blockquote', 'code', 'table', 'thematicBreak'],
        convert: ({ node, docx: api, convertChildren, report }) => {
          // The shared codec first verifies visible-content fingerprints. Then
          // validate our own payload/version; never resurrect stale Word text.
          const metadata = api.roundTrip.readMetadata(node);
          if (metadata?.version === 1) {
            try { return [{ type: 'docxSource', value: approvalCardMarkdown(readApprovalCard(metadata.data)) }]; }
            catch { /* Invalid business data must use the current visible content. */ }
          }
          report({ code: 'approval-card-source-unavailable', severity: 'warning', disposition: 'approximated',
            message: 'Card metadata is unavailable; current visible content was retained.' });
          const children = (node.children ?? []).flatMap(child => child.type === 'table' && child.children?.length === 1
            && child.children[0]?.children?.length === 1 ? child.children[0].children[0]!.children ?? [] : [child]);
          return convertChildren(children);
        } },
    }),
  };
}

```

### export-nodes/data.ts

```ts
import type { ApprovalCardData } from './types';

/** @param value - Untrusted JSON payload. @returns Bounded, plain business data or an explicit validation error. */
export function readApprovalCard(value: unknown): ApprovalCardData {
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected a card object.');
  const data = value as Record<string, unknown>;
  if (Object.keys(data).some(key => !['title', 'paragraphs'].includes(key))
    || typeof data.title !== 'string' || !data.title.trim() || data.title.length > 120
    || !Array.isArray(data.paragraphs) || !data.paragraphs.length || data.paragraphs.length > 100
    || data.paragraphs.some(text => typeof text !== 'string' || !text.trim() || text.length > 2000)
    || new TextEncoder().encode(JSON.stringify(data)).byteLength > 24_000) throw new Error('Invalid or oversized card data.');
  return { title: data.title, paragraphs: [...data.paragraphs] as string[] };
}

/** @param data - Validated payload. @returns Portable Markdown with a fence longer than any payload delimiter. */
export function approvalCardMarkdown(data: ApprovalCardData): string {
  const json = JSON.stringify(data, null, 2);
  const fence = '`'.repeat(Math.max(2, ...(json.match(/`+/gu) ?? []).map(run => run.length)) + 1);
  return `${fence}approval-card\n${json}\n${fence}`;
}

```

### export-nodes/types.ts

```ts
import type { Parent, Paragraph } from 'mdast';

/** Business data serialized inside an approval-card Markdown fence. */
export interface ApprovalCardData {
  /** Plain title, at most 120 characters. */ readonly title: string;
  /** Plain paragraphs; no HTML, code execution or resource URLs. */ readonly paragraphs: readonly string[];
}

/** Operation-local conversion node; never installed in the editor document. */
export interface ApprovalCardNode extends Parent {
  /** Unique exporter ownership; ordinary code remains untouched. */ type: 'exampleApprovalCard';
  /** Validated business payload used for Word source recovery. */ card: ApprovalCardData;
  /** Native Markdown paragraph children handled by the shared converter. */ children: Paragraph[];
}

declare module 'mdast' {
  /** Register the operation-local card as a document block. */
  interface RootContentMap {
    /** Export-only business card. */ exampleApprovalCard: ApprovalCardNode;
  }
  /** Allow the same card inside containers that accept block content. */
  interface BlockContentMap {
    /** Export-only business card inside a supported container. */ exampleApprovalCard: ApprovalCardNode;
  }
}

```

`title` 和 `paragraphs` 都是纯文本。示例限制字段、长度和数量，无效数据保留代码并产生诊断。
长卡片允许跨页，不设置固定行高。需要富文本正文时应定义清晰的子节点合同，使用
`convertChildren`；不要把用户 HTML 直接拼进 XML。

```ts
import { exportMarkdownDocx } from '@quirekit/markdown-docx'
import { exportMarkdownPdf } from '@quirekit/md-editor-export/pdf'
import { createApprovalCardExtension } from './export-nodes/native'
import { approvalCardMarkdown } from './export-nodes/data'

const markdown = approvalCardMarkdown({ title: 'Approval card', paragraphs: ['Ready for review.'] })
const extensions = [createApprovalCardExtension()]
const template = { fonts: { body: { latin: 'Arial' } } }
const word = await exportMarkdownDocx(markdown, { extensions, template })
const pdf = await exportMarkdownPdf(markdown, { extensions, template, ownerDocument: document })
```

这段最小调用使用拉丁文字。中文与自定义字体必须按 [PDF 与 OFD 导出](/guide/md-editor#pdf-与-ofd-导出)
准备可嵌入字节，并在度量前加载相同字体。`word.data` / `pdf.data` 是下载内容，诊断通过
`diagnostics` 返回；代码本身不触发下载或修改编辑器。

## 使用同一转换器导出 OFD

商业包 `@quirekit/md-editor-export` 的 `/ofd` 入口与 PDF 共享临时文档准备和 Classic 分页，
直接生成 OFD。保留上面的 `markdown`、`extensions`、`template`，显式传入宿主字体：

```ts
import { exportMarkdownOfd, type MarkdownOfdOptions } from '@quirekit/md-editor-export/ofd'

async function exportCardOfd(fonts: NonNullable<MarkdownOfdOptions['fonts']>, ownerDocument: Document) {
  return exportMarkdownOfd(markdown, { extensions, template, fonts, ownerDocument })
}
```

OFD 没有 PDF 的标准字体，拉丁文字也必须提供完整 TTF/OTF 字节；`fonts` 的 `family`、
`aliases` 或 `acceptedSubstitutions` 应覆盖模板字体，并在调用前将相同物理字体加载到
浏览器用于度量。默认嵌入 Unicode 子集，`fontEmbedding: 'full'` 可选择完整字体。
无浏览器环境提供 `textMeasurer`，公式另提供 `rasterizeFormula`。OFD 同样返回
`data`、`pageCount`、`diagnostics`，支持 `signal` 和 `lossPolicy`。

原生卡片保持文字，图片节点和公式仍为图片；DOCX 的 Custom XML 恢复数据不会写入 OFD。
图片资源、模板宽度、失败降级与下面的取消约定都与 PDF 相同。OFD 字体和阅读器支持边界见
[PDF 与 OFD 导出](/guide/md-editor#pdf-与-ofd-导出)。

## 图片节点

下面把 `progress-chart` 围栏中的 JSON 数组（例如 `[25,50,75]`）绘制为有界 PNG。
它只使用显式传入的 `Document` 创建离屏 canvas，不读取实时节点或请求外部资源。
图像颜色与文字由渲染器决定；若要跟随导出模板，宿主应把解析后的颜色、字体传给自己的渲染器。

### export-nodes/image.ts

```ts
import { createMarkdownDocxCodeImageExtension, type MarkdownDocxCodeImageRequest, type MarkdownDocxExtension } from '@quirekit/markdown-docx';

/**
 * Draws a bounded chart from source data without reading a mounted editor view.
 * @param request - A progress-chart fence containing a JSON number array.
 * @param ownerDocument - Explicit browser capability owned by this export.
 * @param signal - Cancellation of this export, not a global editor signal.
 * @returns A complete PNG data URI; malformed data throws for the shared source fallback.
 */
export async function renderProgressChart(request: MarkdownDocxCodeImageRequest, ownerDocument: Document, signal?: AbortSignal): Promise<string> {
  signal?.throwIfAborted();
  if (request.language !== 'progress-chart' || request.value.length > 2000) throw new Error('Unsupported chart input.');
  const values: unknown = JSON.parse(request.value);
  if (!Array.isArray(values) || !values.length || values.length > 12
    || values.some(value => typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100)) throw new Error('Expected 1–12 values between 0 and 100.');
  const canvas = ownerDocument.createElement('canvas');
  // Raster dimensions are fixed and bounded. The common exporter separately
  // fits this image to the current page/cell width without changing its ratio.
  canvas.width = 1200; canvas.height = 480;
  try {
    const context = canvas.getContext('2d');
    if (!context) throw new Error('Canvas 2D is unavailable.');
    context.fillStyle = '#FFFFFF'; context.fillRect(0, 0, canvas.width, canvas.height);
    context.fillStyle = '#172033'; context.font = 'bold 32px sans-serif'; context.fillText('Progress', 40, 52);
    const step = 1120 / values.length;
    values.forEach((value: number, index: number) => {
      const height = value * 3;
      context.fillStyle = '#4F63D8'; context.fillRect(40 + step * index, 410 - height, step * .65, height);
      context.fillStyle = '#172033'; context.font = '24px sans-serif'; context.fillText(String(value), 40 + step * index, 444);
    });
    signal?.throwIfAborted();
    return canvas.toDataURL('image/png');
  } finally { canvas.width = 0; canvas.height = 0; }
}

/**
 * Registers the image recipe for a standalone conversion operation.
 * @param ownerDocument - Browser capability used for offscreen rendering.
 * @param signal - Optional operation cancellation.
 * @param metadata - Word source recovery; PDF has no round-trip store.
 * @returns The one code-image owner. Merge other languages into its renderer when composing with Mermaid.
 */
export function createProgressChartExtension(ownerDocument: Document, signal?: AbortSignal, metadata = true): MarkdownDocxExtension {
  return createMarkdownDocxCodeImageExtension({ languages: ['progress-chart'], metadata,
    render: request => renderProgressChart(request, ownerDocument, signal) });
}

```

为每次导出创建一个 `AbortController`，将它的 `signal` 同时传入资源回调和 PDF 选项。
把 `createProgressChartExtension(document, controller.signal)` 加入同一份 `extensions`，
并调用 `exportMarkdownPdf(markdown, { extensions, ownerDocument: document, signal: controller.signal })`。
关闭对话框或更换文档时调用 `abort()`。不支持 `AbortSignal` 的第三方渲染器可能继续计算，
宿主仍须在完成后检查信号并丢弃过期结果。Word 宿主也应在发布结果前检查信号。

`render` 返回有效 PNG/JPEG data URI；返回 `null`、无效内容或抛出异常时，内置转换器保留
原代码并报告 `code-image-unavailable`。PDF 中的图表文字不可单独选择。`metadata: false`
明确关闭 Word 源码恢复并产生损失诊断；PDF 即使接收开启元数据的同一转换器，也只保留可见图片。

### 与 Mermaid 组合

`createMarkdownDocxCodeImageExtension` 的多个实例会认领相同临时节点，不能各注册一个。
MD Editor 会用同名宿主扩展替换内置图片转换器，因此只注册 `progress-chart` 也会替换默认 Mermaid
配置。需要两种语言时，用一个 `languages: ['mermaid', 'progress-chart']` 和一个分派回调，
由宿主同时提供 Mermaid 与业务图表渲染；不要改扩展名称来绕过所有权检查。

## 在 MD Editor 中接入

```vue
<MdEditor :docx="{ extensions: [createApprovalCardExtension()] }" :exporters="[mdPdfExport(), mdOfdExport()]" />
```

导出插件收到的 `options` 已包含当前模板、内置与自定义转换器，自定义节点在 Word、PDF、OFD 中
使用同一份转换器。仅传 `docx.extensions` 不会安装商业 PDF/OFD 能力；未安装的格式不出现在菜单中。
非 Vue 宿主直接调用各格式的导出函数，显式传同一模板与转换器即可。

## 恢复、诊断与验收

Word 导入先验证内容指纹，再由扩展检查 payload 版本和字段。用户修改了 Word 中的文字、
图片或相关样式后，导入保留新内容，不恢复旧业务数据。未安装扩展、无效元数据或未知版本
同样使用可见回退。恢复的是校验后的业务数据，示例会规范化 JSON 排版，不保证逐字符恢复原围栏。

`best-effort` 返回结果和诊断，`strict` 遇到任意警告拒绝导出；圆角近似、图片化或缺字体
都可能使严格模式拒绝。PDF 本身不承诺源码恢复，调用方不能将 Word 的 metadata 信息当成
PDF 可逆性的证明。

交付自己的转换器前，至少验证：普通节点不受影响；缺资源时保留内容；实际 DOCX/PDF/OFD 有
可见文字或图像；长内容跨页及嵌套图片不越界；不同模板生效；取消不下载旧结果；修改 Word
后不会恢复过期数据。公开 recipe 的测试直接执行同一份源码，而不是另一份近似实现。
