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 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
parse(source, next)extends parsing only when new Markdown syntax is needed. Existing code fences need no new parser.prepare(root, context)validates data, acquires resources and creates temporary nodes. Complete asynchronous work here.addDocx()returns the samedocx.codec.node()contributions used by Classic.convertChildrenreuses standard conversion;resolveService(markdownExportTemplateService)reads the current resolved template.- 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:
```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.
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);
} },
}),
};
}
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.
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.
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:
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 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.
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
<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.