导出适配器读取 Markdown 源码快照,与你的 Vue/React 节点视图分开。节点的屏幕外观不会自动
进入文件;需要明确把它转换为可打印内容。Word、PDF 与 OFD 可以复用同一份 extensions。
模板的选择方式见 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 显示节点。
转换生命周期
parse(source, next):仅当需要新 Markdown 语法时扩展解析。已有代码围栏可直接复用。prepare(root, context):校验业务数据、获取资源,把匹配节点投影为临时节点。异步工作在此完成。addDocx():返回与 Classic 相同的docx.codec.node()贡献。convertChildren复用已有转换,resolveService(markdownExportTemplateService)读取本次已解析模板。- 公共呈现阶段约束表格、图片和页边距;Word 编译为 DOCX,PDF/OFD 转入临时 Classic 文档并分页编码。
每个 modelType 只有一个转换所有者。不要直接认领所有 code 节点后丢弃不认识的语言。
下面先把验证成功的 approval-card 围栏改成独立的 exampleApprovalCard,其他代码继续使用默认转换。
原生业务卡片
这是独立的导出接入示例,没有给编辑器新增卡片视图或插入菜单。业务数据保存在普通围栏中:
```approval-card
{"title":"Approval card","paragraphs":["Review release scope.","Confirm rollback plan."]}
```
完整实现使用公开包入口。TypeScript 消费者需要 @types/mdast;通过 declare module 'mdast'
声明临时节点。卡片复用模板的 containers.note 外观,单元格内距使用 Word 的 twips,
边线宽度使用八分之一磅;正文样式、字体和分页仍由公共导出链决定。
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 和 paragraphs 都是纯文本。示例限制字段、长度和数量,无效数据保留代码并产生诊断。
长卡片允许跨页,不设置固定行高。需要富文本正文时应定义清晰的子节点合同,使用
convertChildren;不要把用户 HTML 直接拼进 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 })
这段最小调用使用拉丁文字。中文与自定义字体必须按 PDF 与 OFD 导出
准备可嵌入字节,并在度量前加载相同字体。word.data / pdf.data 是下载内容,诊断通过
diagnostics 返回;代码本身不触发下载或修改编辑器。
使用同一转换器导出 OFD
商业包 @quirekit/md-editor-export 的 /ofd 入口与 PDF 共享临时文档准备和 Classic 分页,
直接生成 OFD。保留上面的 markdown、extensions、template,显式传入宿主字体:
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 导出。
图片节点
下面把 progress-chart 围栏中的 JSON 数组(例如 [25,50,75])绘制为有界 PNG。
它只使用显式传入的 Document 创建离屏 canvas,不读取实时节点或请求外部资源。
图像颜色与文字由渲染器决定;若要跟随导出模板,宿主应把解析后的颜色、字体传给自己的渲染器。
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 中接入
<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 的测试直接执行同一份源码,而不是另一份近似实现。