# Export custom nodes to Word, PDF and OFD

Share native content conversion and image rendering, with templates, resource failures, cancellation and Word metadata recovery.

Export adapters read a Markdown snapshot independently of your Vue/React node views. Screen appearance
does not enter a file automatically: provide an explicit printable representation. Word, PDF and OFD can
share the same `extensions`. See [MD Editor](/en/guide/md-editor#export-templates) for template selection.

## Quire Render and multi-format export

**Quire Render** is QuireKit's unified rendering engine. Word, PDF and OFD first share semantic node
conversion and templates. Word compiles editable DOCX, while PDF/OFD uses Classic pagination and
passes each page's content to the corresponding Quire Render backend.

| Name | Responsibility |
| --- | --- |
| `qkView` | Compose declarative components and resolve their local layout |
| Quire Display List | Represent text, paths, images and clipping with `QuireDisplayList` |
| Render Backend | Output drawing commands to Canvas, SVG, PDF or OFD |

A dedicated layout engine owns document pagination; `qkView` is one way to produce drawing commands.
Markdown export still uses the native-content or image conversions below. Vue/DOM views and `qkView`
interactive controls do not automatically become Word or fixed-layout content. Font, resource and
loss handling remain specific to each backend.

## Choose a representation

| Requirement | Converter output | Word | PDF / OFD |
| --- | --- | --- | --- |
| Callouts, business cards and readable fields | Native paragraphs, lists, tables, shading and borders | Editable text | Selectable text with Classic pagination |
| Charts or complex graphics | Host-rendered PNG/JPEG | Image, optionally with recovery metadata | Image, proportionally fitted to available width and height |
| Recover business data on import | Versioned Custom XML metadata with visible content | Restored after validation | This recovery data is not saved |
| Custom PDF/OFD vector drawing or pagination | No dedicated public API yet | — | Requires a separate extension to shared layout |

PDF/OFD consumes visible LitAST understood by the existing Classic assembly. It does not execute DOCX
package passes or install arbitrary third-party Classic schemas. Private XML or an unknown LitAST
node type alone cannot make a custom node appear in PDF/OFD.

## Conversion lifecycle

1. `parse(source, next)` extends parsing only when new Markdown syntax is needed. Existing code fences need no new parser.
2. `prepare(root, context)` validates data, acquires resources and creates temporary nodes. Complete asynchronous work here.
3. `addDocx()` returns the same `docx.codec.node()` contributions used by Classic. `convertChildren` reuses standard conversion;
   `resolveService(markdownExportTemplateService)` reads the current resolved template.
4. Shared presentation constrains tables, images and margins. Word compiles a DOCX; PDF/OFD imports a temporary Classic document for pagination and encoding.

Each `modelType` has one conversion owner. Avoid claiming every `code` node and dropping unfamiliar
languages. This recipe first converts validated `approval-card` fences into `exampleApprovalCard`;
ordinary code keeps its standard converter.

## A native business card

This is an independent export recipe. It does not install a new editor view or insertion menu.
Business data is stored in an ordinary fence:

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

The complete implementation uses public package exports. TypeScript consumers need `@types/mdast`
and declare their temporary node with `declare module 'mdast'`. The card uses `containers.note` from
the selected template. Cell margins use native twips and border width uses eighth-points; the shared
export pipeline owns paragraph styles, fonts and pagination.

### 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` and `paragraphs` contain plain text. Fields, lengths and counts are bounded; invalid input
stays as code with a diagnostic. Long cards may split across pages without fixed row heights.
For rich content, define a clear child-node contract and use `convertChildren`; do not interpolate
user HTML into 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 })
```

This minimal call uses Latin text. For Chinese or custom fonts, supply embeddable bytes and load
matching measurement faces as described in [PDF and OFD export](/en/guide/md-editor#pdf-and-ofd-export).
`word.data` / `pdf.data` contain download bytes and `diagnostics` contains the report. The code does
not download files or mutate an editor itself.

## Reuse the converter for OFD

The `/ofd` entry of the commercial `@quirekit/md-editor-export` package shares temporary document
preparation and Classic pagination with PDF, then encodes OFD directly. Reuse the `markdown`,
`extensions` and `template` above with explicit host fonts:

```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 has no PDF standard fonts: even Latin text requires complete TTF/OTF bytes. The fonts' `family`,
`aliases` or `acceptedSubstitutions` should cover the template, and the host must load the same physical
faces into the browser before measurement. Unicode subset embedding is the default;
`fontEmbedding: 'full'` embeds complete faces. Non-browser hosts supply `textMeasurer` and, for equations,
`rasterizeFormula`. OFD returns `data`, `pageCount` and `diagnostics`, with `signal` and `lossPolicy`.

Native cards retain text; image nodes and equations remain images. DOCX Custom XML recovery data is
not included in OFD. Resource preparation, template width limits, failures and cancellation follow
the PDF contract below. See [PDF and OFD export](/en/guide/md-editor#pdf-and-ofd-export) for font and reader boundaries.

## Image nodes

This recipe renders a `progress-chart` fence containing a JSON array, such as `[25,50,75]`, into a
bounded PNG. It creates an offscreen canvas using an explicit `Document`, without reading live
node views or requesting external resources. The renderer owns image colors and text. To follow
an export template, pass its resolved colors and fonts to your own renderer.

### 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) });
}

```

Create an `AbortController` for each export. Pass its `signal` to resource callbacks and PDF options.
Add `createProgressChartExtension(document, controller.signal)` to the shared `extensions`, then call
`exportMarkdownPdf(markdown, { extensions, ownerDocument: document, signal: controller.signal })`,
and abort when the dialog closes or document changes. A third-party renderer without cancellation
support may keep computing; check the signal after it completes and discard stale results.
Word hosts should also check the signal before publishing a result.

`render` returns a valid PNG/JPEG data URI. A `null` result, invalid data or thrown error retains the
original code with `code-image-unavailable`. Chart text is not individually selectable in PDF.
`metadata: false` disables Word source recovery and reports the loss. PDF only retains the visible
image even when the same converter has metadata enabled.

### Compose with Mermaid

Multiple `createMarkdownDocxCodeImageExtension` instances claim the same temporary node type and
cannot be registered separately. MD Editor replaces its builtin image converter with a same-name
host extension, so registering only `progress-chart` also replaces its default Mermaid configuration.
For both languages, use one `languages: ['mermaid', 'progress-chart']` configuration and a dispatching
renderer. The host provides both implementations. Renaming an extension does not bypass node ownership.

## Install in MD Editor

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

Export plugins receive `options` that already contain the selected template and builtin/custom
converters, so custom nodes use one converter for Word, PDF and OFD. `docx.extensions` does not
install commercial PDF or OFD capability; formats that are not installed do not appear in the menu.
Framework-neutral hosts call the format-specific export functions with the same template and converters explicitly.

## Recovery, diagnostics and acceptance

Word import verifies the visible-content fingerprint before the extension validates its payload
version and fields. When users edit Word text, images or relevant styles, retain their current
content instead of restoring old business data. Missing extensions, invalid metadata and unknown
versions also use visible fallback. Recovery restores validated business data; this recipe normalizes
JSON formatting and does not promise byte-for-byte fence recovery.

`best-effort` returns bytes and diagnostics. `strict` rejects any warning, including rounded-corner
approximation, image projection or missing fonts. PDF does not promise source recovery; a Word
metadata diagnostic must not be interpreted as evidence that the PDF is reversible.

Before shipping a converter, verify ordinary nodes remain unchanged; failed resources retain content;
real DOCX/PDF/OFD files contain visible text or images; long content paginates and nested images fit;
templates apply; cancellation cannot download an old result; and Word edits invalidate stale source.
The public recipes are exercised directly by their tests rather than copied into a second implementation.
