使用 JSON DSL 导出自定义节点

Available in Start planBetav0.17.0

Beta 功能

DSL 正在逐步稳定,但在正式可用之前,仍可能会看到一些增量改进。如果你依赖它,请固定确切的包版本。线上的格式是有版本号的(dslVersion: "1.0");未来版本不会悄悄改变本文档的结构。

基于函数的自定义节点 API 允许你通过编写一个小型 JavaScript 函数,将任何自定义 Tiptap 节点渲染为 DOCX。这在编辑器扩展内部效果很好,但函数无法通过 HTTP 传输——因此,历史上 DOCX REST API 根本没有办法渲染自定义节点。

customNodeDsl 选项弥补了这一缺口。它是一种小型、可序列化的 JSON 语言,用于描述自定义节点应如何渲染为 DOCX。同一份 JSON 可在两个场景中使用:在浏览器中传给 ExportDocx.configure({ customNodeDsl }),或者在 REST 请求体中作为 customNodeDsl 字段传入。


何时使用它

你想要……使用
在浏览器中调用 editor.exportDocx() 时渲染自定义节点,并且拥有完整的 JavaScript 灵活性。函数 APIcustomNodes)。
在调用 POST /v2/convert/export/docx 时渲染自定义节点。customNodeDsl(本页)。
在你的浏览器应用与服务端渲染之间共享相同的自定义节点导出逻辑。customNodeDsl(本页)。
从两个场景渲染同一个自定义节点,并以单一来源为准。customNodeDsl(本页)。

这两种 API 可以在编辑器扩展上共存。当同一种节点类型同时出现在 customNodescustomNodeDsl 中时,函数定义优先生效,并且每次导出调用只会输出一次 console.warn。现有基于函数的用户不会看到行为变化。

用 TypeScript 编写?使用 builder

本页文档说明的是 JSON 线上的格式 —— 也就是在 convert REST 接口中传输内容的权威参考。如果你是在 TypeScript 代码库中编写规则,可以考虑使用 DSL builder—— 一个流式、带类型的 API,会生成完全相同的 JSON,并在每个 prop 上提供自动补全以及编译期包含安全性。该 builder 通过 @tiptap-pro/extension-export-docx/dsl 子路径导入。


第一个示例

一个自定义的 hintbox 块节点,应在 DOCX 中渲染为带样式的段落:

import { ExportDocx } from '@tiptap-pro/extension-export-docx'

ExportDocx.configure({
  customNodeDsl: {
    dslVersion: '1.0',
    nodes: [
      {
        type: 'hintbox',
        nodeKind: 'block',
        render: {
          emit: {
            element: 'Paragraph',
            props: { style: 'Hintbox' },
            children: { $children: { as: 'inline', marks: 'default' } },
          },
        },
      },
    ],
  },
})

通过 REST 使用同样的 JSON:

curl --output example.docx -X POST "https://api.tiptap.dev/v2/convert/export/docx" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -H "X-App-Id: YOUR_APP_ID" \
    -H "Content-Type: application/json" \
    -d '{
      "doc": "{\"type\":\"doc\",\"content\":[{\"type\":\"hintbox\",\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}]}",
      "exportType": "blob",
      "customNodeDsl": {
        "dslVersion": "1.0",
        "nodes": [
          {
            "type": "hintbox",
            "nodeKind": "block",
            "render": {
              "emit": {
                "element": "Paragraph",
                "props": { "style": "Hintbox" },
                "children": { "$children": { "as": "inline", "marks": "default" } }
              }
            }
          }
        ]
      }
    }'

Hintbox 段落样式本身通过 styleOverrides 声明,方式与你为函数 API 声明它时完全相同。


顶层文档

{
  "dslVersion": "1.0",
  "nodes": [
    /* CustomNodeRule,… */
  ],
}
字段类型说明
dslVersion"1.0"必填且必须是精确字面量。任何其他值都会以 DOCX_DSL_UNKNOWN_VERSION 拒绝。
nodesCustomNodeRule[]必填。每个 ProseMirror 节点类型对应一条规则。最多 128 项。重复的 type 值会以 DOCX_DSL_DUPLICATE_NODE_TYPE 拒绝。

以下根级键名 为未来版本保留,当前若出现将以 DOCX_DSL_RESERVED_SHAPE 拒绝,而不会被静默忽略:requiresStylescontributedStylesexternalRefslimits。这使未来的启用保持安全——今天能编译通过的载荷,明天也会以完全相同的方式继续编译通过。


CustomNodeRule

一条规则描述一个 ProseMirror 节点类型:

{
  "type": "hintbox",
  "nodeKind": "block",
  "render": {
    /* RenderProgram */
  },
}
字段类型默认值说明
typestringrequired此规则匹配的 PM 节点类型(例如 "hintbox""mention")。
nodeKind"block" | "inline" | "auto""auto"源 PM 节点声明的类型。"auto" 允许校验器从规则的 emit 形状推断。
renderRenderProgram | nullrequired要渲染的内容。null 会删除匹配的 PM 节点及其所有后代

若要让包裹节点不输出内容,同时让子节点正常渲染,请使用 render: { emit: { "$children": { "as": "block" } } },而不是 render: null


RenderProgram

{
  "emit": /* RenderNode | RenderNode[] | null */
}

RenderProgram 至少必须声明一个 emit。如果既没有 emit,也没有(保留的)contribute 字段,则会以 DOCX_DSL_INVALID_SHAPE 拒绝。

emit: null 会让这个 PM 节点不渲染任何内容,但不会删除后代。若要完全删除后代,请在规则本身上设置 render: null


RenderNodes

emit 下的每一项(以及 ElementNode 的子节点)都是一个 RenderNode。共有七种形状,全部通过结构来区分——线上格式中没有 kind 标签:

形状区分方式含义
null字面量此处不渲染任何内容。
array原生 JS 数组隐式片段——按顺序渲染每一项。
{ "element": …, … }element通过适配器注册表构建一个 docx.js 元素。
{ "$children": { … } }$children遍历 PM 节点的子内容,并分派每个子节点。
{ "$text": …, … }$text用于输出单个文本运行的便捷写法。
{ "$fragment": [ … ] }$fragment显式的同级渲染节点片段。
{ "$if": { … } }$if结构化条件判断。
{ "$switch": { … } }$switch结构化多分支条件判断。

如果一个对象混用了两个以 $ 开头的键(例如 { "$text": …, "$children": … }),则会以 DOCX_DSL_INVALID_SHAPE 拒绝。只能选一个。

ElementNode

最常见的形状——实例化一个 docx.js 元素:

{
  "element": "Paragraph",
  "props": { "style": "Hintbox" },
  "children": { "$children": { "as": "inline", "marks": "default" } },
  "applyMarks": "node",
  "inheritOverrides": true,
}
字段类型默认值说明
elementElementNamerequired允许的元素之一(见 元素目录)。
propsRecord<string, ValueExpr>{}元素属性。每个值都可以是字面量或任意 值表达式。未知的属性键会依据该元素的属性 schema 拒绝。
childrenRenderNode | RenderNode[]undefined子渲染节点。元素的适配器决定允许哪些内容(见 包含关系)。
applyMarksApplyMarksPolicyundefined仅对 inline 元素有效。将该规则自身 PM 节点上的 marks 合并到运行中。参见 marks
inheritOverridesbooleantrue当为 false 时,仅对该元素跳过全局 *Overrides 套件(例如 paragraphOverrides)。

$children

遍历 node.content 并通过标准转换器分派每个子节点:

{ "$children": { "as": "inline", "marks": "default" } }
字段类型默认值说明
as"block" | "inline" | "table-row" | "table-cell"required期望的子节点类型。会根据父元素的 childKind 进行校验。不匹配会以 DOCX_DSL_INVALID_CONTEXT 拒绝。
marksMarkPolicy"default"仅在 as: "inline" 时有效。如何映射文本子节点上的 marks。参见 marks
wrapInlineInParagraphbooleanfalse仅在 as: "block" 时有效。当为 true 时,连续的 inline 子节点会被缓冲到一个默认 Paragraph 中,而不是直接拒绝。对于需要接收仅 inline 内容的表格单元格很有用。

$text

用于输出单个文本运行的便捷写法:

{ "$text": { "$ref": "node.attrs.label" }, "marks": "default", "default": "(未命名)" }
字段类型默认值说明
$textValueExprrequired解析为字符串。非字符串会通过 String() 强制转换。
marks"default" | "none""default""default" 会运行标准的 mark 流程;"none" 会跳过它。
defaultstringundefined当解析结果为 ""nullundefined 时使用的替代字符串。

{ "$text": e }marks: "default" 等价于 { "element": "TextRun", "props": { "text": e } } 并应用标准 mark 流程——只是写起来更短。

$fragment

显式的同级渲染节点片段。直接使用数组也能达到同样效果:

{ "$fragment": [ /* RenderNode,… */ ] }
// —— 或者等价地 ——
[ /* RenderNode,… */ ]

$if

结构化条件判断:

{
  "$if": {
    "test": { "$ref": "node.attrs.featured" },
    "then": { "element": "Paragraph", "props": { "style": "Featured" } },
    "else": null,
  },
}
字段类型默认值说明
testValueExprrequired会被强制转换为布尔值。falsenullundefined0"" 为假值;其他值都为真值。
thenRenderNoderequiredtest 为真值时渲染的分支。
elseRenderNodenulltest 为假值时渲染的分支。

$switch

多分支条件判断。查找时会与 cases 的键进行精确匹配——不支持正则或通配符匹配:

{
  "$switch": {
    "on": { "$ref": "node.attrs.variant" },
    "cases": {
      "warning": { "element": "Paragraph", "props": { "style": "CalloutWarning" } },
      "info": { "element": "Paragraph", "props": { "style": "CalloutInfo" } },
    },
    "default": { "element": "Paragraph", "props": { "style": "Callout" } },
  },
}

on 在运行时必须解析为字符串;非字符串会以 DOCX_DSL_RUNTIME_TYPE_MISMATCH 拒绝。default 是当没有任何 case 匹配时的回退;如果省略,则默认值为 null(不渲染任何内容)。

$switch 也可作为值表达式使用——参见 值形式的 $switch


元素目录

该 DSL 内置了一组固定的受支持元素。新增元素属于版本变更。未知名称会以 DOCX_DSL_UNKNOWN_ELEMENT 拒绝。

元素类型允许出现在子元素类型备注
Paragraphblockdocument, blockinline标准文本块。
TextRuninlineinline(leaf)标准文本片段。
ExternalHyperlinkinlineinlineinline (仅限 TextRun)将一个或多个 TextRun 包裹在超链接中。
Tableblockdocument, blocktable-row子元素填充到 rows 中,而不是 children
TableRowtable-rowtable-rowtable-cell表格中的一行。
TableCelltable-celltable-cellblock行中的一个单元格;包含块级内容。
PageBreakblockdocument, block(leaf)生成一个包含 docx 分页符运行的段落。若要在段落内插入行内分页符,请使用 { "element": "TextRun", "props": { "break": 1 } }

ImageRun 属于 v1。图像数据仅通过内置的 image PM 节点流转——你的 DSL 规则可以对其执行 $children,但 DSL 本身不会生成图像字节。这是 REST 表面上的安全保证。

包含关系

验证器会在编译时拒绝所有非法的父/子组合,并给出违规节点精确的 dslPath

父插槽 ↓ \ 子元素类型 →blockinlinetable-rowtable-cell
Document body
Paragraph.children
ExternalHyperlink.children✓(仅限 TextRun)
Table.rows
TableRow.children
TableCell.children(仅在 wrapInlineInParagraph 时)

不匹配示例:

{
  "error": "Element \"Paragraph\" cannot appear in \"inline\" slot.",
  "code": "DOCX_DSL_INVALID_CONTEXT",
  "dslPath": "nodes[1].render.emit.children[0]",
}

元素属性

每个元素都会根据严格的 schema 验证其 props。未知键会以 DOCX_DSL_INVALID_PROP 拒绝。

Paragraph

{
  "style": "Hintbox",
  "alignment": "left" | "center" | "right" | "justified" | "justify" | "both",
  "heading": "heading1" | "heading2" | "heading3" | "heading4" | "heading5" | "heading6",
  "spacing":  { "before": 120, "after": 120, "line": 240, "lineRule": "auto" | "exact" | "atLeast" },
  "numbering":{ "reference": "bullet-list" | "ordered-list", "level": 0, "instance": 1 },
  "indent":   { "left": 360, "right": 0, "firstLine": 360, "hanging": 0 },
  "pageBreakBefore": false
}

间距和缩进值使用 twips(1 twip = 1/20 磅)。如果你更想用更友好的单位,可以使用 $unit 辅助函数——pointsToTwipsinchesToTwips 等。

TextRun

{
  "text": "hello",
  "bold": true,
  "italics": true,
  "underline": true,
  "underline": { "type": "single" | "double" | "thick" | "dotted" | "dash" | "wave", "color": "4F46E5" },
  "strike": true,
  "doubleStrike": true,
  "superScript": true,
  "subScript": true,
  "size": 24,
  "color": "1F2937",
  "font": "Inter",
  "highlight": "yellow",
  "shading": { "type": "solid" | "clear", "fill": "F3F4F6", "color": "1F2937" },
  "break": 1,
  "style": "InlineCode"
}

size 使用 half-points(因此一个 12pt 的片段应为 size: 24)。color、shading 的 fillcolor,以及 underline 的 color 都是 不带 # 的 6 位十六进制字符串(如果数据带有 # 前缀,请使用 hexNoHash 转换器或 normalizeColor 单元)。

{ "link": "https://tiptap.dev" }

link 值是必需的,并且会校验是否以 httphttpsmailtotel 开头。其他协议会在编译时被拒绝。链接长度上限为 2048 个字符。

ExternalHyperlink 至少需要一个 TextRun 子元素;空超链接会在运行时以 DOCX_DSL_INVALID_CONTEXT 拒绝。

Table

{
  "width":   { "size": 100, "type": "pct" | "auto" | "dxa" | "nil" },
  "layout":  "fixed" | "autofit",
  "columnWidths": [2400, 2400, 2400],
  "margins": { "top": 100, "bottom": 100, "left": 120, "right": 120 },
  "borders": {
    "top":              { "style": "single", "size": 4, "color": "B8D8FF" },
    "bottom":           { "style": "single", "size": 4, "color": "B8D8FF" },
    "left":             { "style": "single", "size": 4, "color": "B8D8FF" },
    "right":            { "style": "single", "size": 4, "color": "B8D8FF" },
    "insideHorizontal": { "style": "single", "size": 4, "color": "B8D8FF" },
    "insideVertical":   { "style": "single", "size": 4, "color": "B8D8FF" }
  }
}

Table 的子元素会填充到 docx.js 的 rows 参数中——你无需自己编写 rows。表格至少需要一行(空表会在运行时被拒绝)。

TableRow

{
  "tableHeader": false,
  "cantSplit":   false,
  "height":      { "value": 480, "rule": "auto" | "exact" | "atLeast" }
}

一行至少需要一个单元格。

TableCell

{
  "width":         { "size": 50, "type": "pct" | "auto" | "dxa" | "nil" },
  "columnSpan":    1,
  "rowSpan":       1,
  "shading":       { "type": "solid" | "clear", "fill": "FFF1CC", "color": "1F2937" },
  "borders":       { "top": , "bottom": , "left": , "right":  },
  "margins":       { "top": 100, "bottom": 100, "left": 120, "right": 120 },
  "verticalAlign": "top" | "center" | "bottom"
}

PageBreak

{}

PageBreak 不接受任何 props。它会生成一个块级段落,其中包含一个 docx 分页符运行。


值表达式

在任何接受 prop 值(或 $text 值、$if.test$switch.on 等)的地方,你都可以传入字面量下面这些带类型的值表达式之一。不是表达式的值——字符串、数字、布尔值、null、数组、普通对象——会原样透传。普通对象会递归遍历,因此像 spacing 这样的嵌套结构可以在任意叶子节点中携带表达式。

表达式语言是刻意保持精简的。没有字符串拼接运算符(请使用 $template),没有数组索引(请使用精确的属性路径),没有 eval,也没有通用计算能力。下面这五种原语足以覆盖实际自定义节点渲染器的需求。

$ref — 读取 PM 节点属性

{
  "$ref": "node.attrs.color",
  "default": "4F46E5",
  "transform": "hexNoHash",
}
字段类型默认值描述
$refstring必填点分路径。仅允许标识符片段(/^[a-zA-Z_][a-zA-Z0-9_]*$/);不支持数组索引,不支持通配符。
defaultValueExprundefined当路径解析为 nullundefined 时使用的替代值。
transformTransformName | TransformName[]undefined在解析后应用的一个或多个白名单后处理器。

允许的路径:

路径返回值
node整个 PM 节点对象。
node.typePM 节点类型名称。
node.attrs整个 attrs 对象。
node.attrs.<key>指定名称的属性(仅支持一层深度——node.attrs.style.color 在 v1 中允许)。
node.textPM 节点的 text 字段(仅对文本节点有意义)。
node.textContent所有后代文本节点的拼接结果——等同于 ProseMirror 的 node.textContent

其他路径会被拒绝:

  • node.contentnode.marksDOCX_DSL_INVALID_REF(v1 中没有遍历模型)。
  • loop.*$parent$siblings$depth$rootDOCX_DSL_RESERVED_SHAPE(为未来版本保留)。
  • 路径中的任何位置出现 __proto__prototypeconstructor 片段 → DOCX_DSL_INVALID_REF(防止原型污染)。
  • 解析结果为函数值 → DOCX_DSL_INVALID_REF

$template — 字符串插值

{ "$template": "@{node.attrs.label}" }

始终返回字符串。替换项使用 {path}(路径语法与 $ref 相同)。{{}} 用于转义字面量大括号。解析后的长度上限为 maxTemplateLength(默认 2000);超出时会在运行时以 DOCX_DSL_RESOURCE_LIMIT 拒绝。

$op — 类型化计算

{ "$op": "mul", "args": [{ "$ref": "node.attrs.size" }, 2] }
运算符参数个数备注
add, mul≥ 2(可变参数)数值型。混用类型会被拒绝。
sub, div恰好 2数值型。
eq, ne, lt, le, gt, ge恰好 2两侧必须是相同的原始类型(数字或字符串)。
and, or≥ 2基于 truthiness 短路。
not恰好 1布尔型。
coalesce≥ 2返回第一个非 null、非 undefined 的参数。

没有 concat 运算符——字符串请使用 $template。没有隐式数值转换——{ "$op": "add", "args": ["3", 1] } 会以 DOCX_DSL_RUNTIME_TYPE_MISMATCH 拒绝。

$unit — 单位 / 类型转换辅助

{ "$unit": "pointsToTwips", "value": 12 }

连接到一组固定的辅助函数。结果类型与该辅助函数的签名一致。

单元输入输出
pixelsToHalfPointsnumber (px)number (half-points)
pixelsToPointsnumber (px)number (points)
pointsToHalfPointsnumber (pt)number (half-points)
pointsToTwipsnumber (pt)number (twips)
lineHeightToDocxnumber (multiplier)number (docx line value)
universalMeasureToTwipsstring "1.5cm"/"10pt"/… or numbernumber (twips)
normalizeColorstring (any CSS color)6-char hex string without #, or null
inchesToTwipsnumber (in)number (twips)
cmToTwipsnumber (cm)number (twips)
mmToTwipsnumber (mm)number (twips)

这些与基于函数的 customNodes API 所使用的是同一组辅助函数,并向 DSL 作者开放,使线上格式与编辑器扩展的运行时语义完全一致。

值形式的 $switch

$switch 可以作为值表达式使用——这在需要根据属性选择 prop 值时很有用:

{
  "color": {
    "$switch": {
      "on": { "$ref": "node.attrs.variant" },
      "cases": { "warning": "F59E0B", "info": "0EA5E9" },
      "default": "1F2937",
    },
  },
}

验证器会根据上下文来区分值形式和结构化 render-tree 形式。在 props 内部得到的是值表达式;在 RenderNode 插槽中得到的是结构化形式。

转换器

$ref 解析后应用(如果路径缺失,则应用到 default):

转换器行为
hexNoHash去掉前导 #;剩余部分必须匹配 /^[0-9A-Fa-f]{6}$/。拒绝非字符串或无效十六进制值。
lower, upper, trim标准字符串操作。拒绝非字符串。
parseIntStrictparseInt(value, 10);拒绝 NaN
parseFloatStrictparseFloat(value);拒绝 NaN
boolean严格:接受 true/false"true"/"false"(大小写不敏感)。拒绝其他一切值。
nullableString空字符串/仅空白字符串 → null;否则返回去除首尾空白后的字符串。

hexNoHash 不是 CSS 颜色规范器(它不理解 rgb()、命名颜色、#abc 这种简写等)。如果你需要这些,请使用 { "$unit": "normalizeColor", "value": … }

Truthiness 规则

对于 $if.test 以及布尔运算 and / or / not / coalesce

假值真值
false, null, undefined, 0, ""其他所有值

除这些显式规则外,不存在隐式类型转换。


标记系统

标记(bolditalicunderlinestrikecodesubscriptsuperscripttextStylehighlightlink)是 DOCX 大部分行级格式的来源。DSL 提供了两个标记钩子:

  1. MarkPolicy 用于 $children$text —— 控制标准管道如何映射文本子节点上的标记。
  2. applyMarks 用于行内元素(TextRunExternalHyperlink)—— 控制规则自身的 PM 节点标记如何合并到输出的 run 上。

MarkPolicy(用于 $children / $text

"marks": "default"           // 标准 Tiptap 标记映射(默认值)
"marks": "none"              // 忽略所有标记
"marks": "node"              // 使用规则自身的 PM 节点标记,而不是每个子节点的标记
"marks": {
  "mode": "default" | "node",
  "overrides": {
    "bold":   { "props": { "color": "DC2626" }, "replace": false },
    "italic": { "replace": true }
  },
  "disable": ["highlight"]
}
字段默认值描述
mode必填(对象形式时)"default" 运行标准标记管道;"node" 针对规则自身的 PM 节点标记运行。
overrides[<mark>].props{}当存在此标记时额外应用的 TextRun 属性。
overrides[<mark>].replacefalse当为 true 时,抑制此标记的标准映射,仅应用覆盖的 props
disable[]完全跳过的标记。它们的标准映射不会运行,且任何匹配的 overrides 条目都会被忽略。

MarkPolicy: "default"$children: { as: "inline" }$text 的默认值——与普通文本 run 接收到的标准管道相同。

applyMarks(用于行内元素)

applyMarksMarkPolicy 不同:它始终使用规则自身的 PM 节点标记(自定义节点自身上的标记),而不是子节点的标记。由于没有其他选择,因此唯一有效的 mode"node"

"applyMarks": "node"
"applyMarks": {
  "mode": "node",
  "overrides": { "underline": { "props": { "color": "EA580C" } } },
  "disable": ["highlight"]
}

'default''none' 会在 applyMarks 上被刻意拒绝——它们只对子节点标记有意义。

当省略 applyMarks 时,规则的 PM 节点标记会被忽略。大多数显式设置样式的行内自定义节点都希望采用这个默认行为——你自己设置颜色和字体,让外层标记自然失效。

覆盖优先级

当多个层级都想设置同一属性时,按以下顺序,越靠后的层级优先生效:

1. 基础 docx 默认值
2. 由 cssStyles 派生的样式               (实验性的 cssStyles 选项)
3. styleOverrides                         (命名的 DOCX 样式)
4. 全局 *Overrides 选项                  (paragraphOverrides、textRunOverrides 等)
5. 内置 Tiptap 节点/标记映射             (标准管道)
6. DSL 子管道标记覆盖                    (MarkPolicy.overrides[<mark>])
7. 行内元素上的 DSL applyMarks 属性
8. 显式的 DSL 元素属性

ElementNode 上的 inheritOverrides: false 会仅跳过该元素的第 4 层——当你想让某个提示框段落不受 paragraphOverrides 影响、但又不影响其他段落时非常有用。

嵌套对象(spacingbordersshading)在每一层都会被替换,而不是深度合并。如果你设置了 spacing: { before: 120 },而底层的 paragraphOverridesspacing: { line: 240 },结果就只是 { before: 120 }。这与现有扩展选项的约定一致。


组合起来看 —— 示例演示

Hintbox(块级,自定义样式,使用标准子节点)

{
  "type": "hintbox",
  "nodeKind": "block",
  "render": {
    "emit": {
      "element": "Paragraph",
      "props": { "style": "Hintbox" },
      "children": { "$children": { "as": "inline", "marks": "default" } },
    },
  },
}

配合 styleOverrides.paragraphStyles 下的 Hintbox 段落条目一起使用。

Mention(行内,模板文本,标记继承)

{
  "type": "mention",
  "nodeKind": "inline",
  "render": {
    "emit": {
      "element": "TextRun",
      "props": {
        "text": { "$template": "@{node.attrs.label}" },
        "color": { "$ref": "node.attrs.color", "default": "4472C4", "transform": "hexNoHash" },
      },
      "applyMarks": "node",
    },
  },
}

一个加粗的 mention 会渲染为 new TextRun({ text: '@alice', color: '4472C4', bold: true })

Callout box(表格,条件样式,基于属性的底纹)

{
  "type": "calloutBox",
  "nodeKind": "block",
  "render": {
    "emit": {
      "element": "Table",
      "props": {
        "width": { "size": 100, "type": "pct" },
        "borders": {
          "top": { "style": "single", "size": 4, "color": "B8D8FF" },
          "bottom": { "style": "single", "size": 4, "color": "B8D8FF" },
          "left": { "style": "single", "size": 4, "color": "B8D8FF" },
          "right": { "style": "single", "size": 4, "color": "B8D8FF" },
        },
      },
      "children": [
        {
          "element": "TableRow",
          "children": [
            {
              "element": "TableCell",
              "props": {
                "shading": {
                  "type": "clear",
                  "fill": {
                    "$unit": "normalizeColor",
                    "value": { "$ref": "node.attrs.backgroundColor", "default": "E6F3FF" },
                  },
                },
                "margins": {
                  "top": { "$unit": "pointsToTwips", "value": 8 },
                  "bottom": { "$unit": "pointsToTwips", "value": 8 },
                  "left": { "$unit": "pointsToTwips", "value": 10 },
                  "right": { "$unit": "pointsToTwips", "value": 10 },
                },
              },
              "children": [
                {
                  "element": "Paragraph",
                  "props": {
                    "style": {
                      "$switch": {
                        "on": { "$ref": "node.attrs.variant" },
                        "cases": { "warning": "CalloutWarning", "info": "CalloutInfo" },
                        "default": "Callout",
                      },
                    },
                  },
                  "children": { "$children": { "as": "inline", "marks": "default" } },
                },
              ],
            },
          ],
        },
      ],
    },
  },
}

代码块(抑制冲突的子标记)

{
  "type": "codeBlock",
  "nodeKind": "block",
  "render": {
    "emit": {
      "element": "Paragraph",
      "props": { "style": "Code" },
      "children": {
        "$children": {
          "as": "inline",
          "marks": { "mode": "default", "disable": ["bold", "italic"] },
        },
      },
    },
  },
}

自定义链接(带标记继承的行内超链接)

{
  "type": "customLink",
  "nodeKind": "inline",
  "render": {
    "emit": {
      "element": "ExternalHyperlink",
      "props": { "link": { "$ref": "node.attrs.href" } },
      "children": [
        {
          "element": "TextRun",
          "props": {
            "text": { "$ref": "node.attrs.label" },
            "style": "Hyperlink",
          },
          "applyMarks": "node",
        },
      ],
    },
  },
}

与函数 API 的冲突策略

当编辑器扩展同时配置了 customNodescustomNodeDsl 时:

场景结果
两者中 type 相同函数优先。每次导出调用、每个冲突类型都会发出一条 console.warn
仅存在于 customNodes函数渲染。
仅存在于 customNodeDsl.nodesDSL 渲染。
两者都不存在保持现有的“找不到自定义节点”行为;该节点会被丢弃,并输出控制台错误。
customNodeDsl.nodes 内部存在重复 type编译错误:DOCX_DSL_DUPLICATE_NODE_TYPE

这就是契约:一个不懂 DSL 的用户升级包版本、无需改动代码,看到的输出应完全一致。添加 customNodeDsl 只会新增规则;它绝不会覆盖现有的基于函数的自定义节点。

REST 接口完全不接受函数 API——只接受 customNodeDsl


资源限制

DSL 是通过公共 REST 接口传入的不可信 JSON。资源上限会在编译期和运行期强制执行。合法的自定义节点程序不应触发这些限制。

限制默认值描述
maxRules128单个 DSL 文档中节点规则的最大数量。
maxRenderDepth32渲染树编译和运行时遍历期间的最大递归深度。
maxRenderNodes1024单个程序中编译后的渲染节点最大数量。
maxValueDepth16值表达式内部的最大递归深度($ref 默认值、$op 参数等)。
maxStringLength10 000计算后字符串属性的最大长度。
maxTemplateLength2 000替换后 $template 结果的最大长度。
maxOpArgs32单个 $op 的最大参数数量。
maxTableRows1 024Table 可输出的 TableRow 子节点最大数量。
maxTableCellsPerRow64单个 TableRow 可输出的 TableCell 子节点最大数量。

在编辑器扩展中调整限制

customNodeDsl 一起传入 customNodeDslLimits?: Partial<CustomNodeDslLimits>

ExportDocx.configure({
  customNodeDsl,
  customNodeDslLimits: {
    maxRules: 256,
    maxRenderDepth: 48,
  },
})

传输格式本身没有 limits 字段。保留的根键 limits 会被 DOCX_DSL_RESERVED_SHAPE 拒绝,因此服务器端管理的限制不会被客户端负载绕过。


错误

每个错误都带有一个稳定的 code 和一个指向出错 JSON 的 dslPath。运行时错误还会附带 nodePath(指向 PM 文档中的路径)和 nodeType

{
  "error": "Expected TextRun.size to be number, got string.",
  "code": "DOCX_DSL_INVALID_PROP",
  "dslPath": "nodes[2].render.emit.props.size",
  "nodePath": "doc.content[4].content[2]",
  "nodeType": "mention",
}

错误代码

Code何时
DOCX_DSL_UNKNOWN_VERSION存在 dslVersion,但不是 "1.0"
DOCX_DSL_INVALID_SHAPEJSON 不符合预期结构(缺少必需字段、类型错误)。
DOCX_DSL_DUPLICATE_NODE_TYPEnodes 中有两条规则声明了相同的 type
DOCX_DSL_UNKNOWN_ELEMENTelement 不在 v1 目录中。
DOCX_DSL_UNKNOWN_OPERATION$op 不在封闭列表中。
DOCX_DSL_RESERVED_SHAPE载荷中出现了为未来版本保留的键。
DOCX_DSL_INVALID_PROP元素属性不符合其 schema(未知键、类型错误)。
DOCX_DSL_INVALID_ENUM枚举属性接收到了封闭列表之外的值。
DOCX_DSL_INVALID_REF$ref 路径格式错误、指向了允许的根之外,或命中了禁止的片段。
DOCX_DSL_INVALID_TEMPLATE$template 格式错误(例如括号不匹配)。
DOCX_DSL_INVALID_UNIT$unit 名称不在封闭列表中。
DOCX_DSL_INVALID_TRANSFORMtransform 名称不在封闭列表中。
DOCX_DSL_INVALID_CONTEXT某个 render 节点位于其元素类型不允许的槽位中(包含性违规)。
DOCX_DSL_INVALID_OP_ARITY传给 $op 的参数数量错误。
DOCX_DSL_RUNTIME_TYPE_MISMATCH值表达式的结果在运行时与预期类型不匹配(例如 $switch.on 解析成了数字)。
DOCX_DSL_RESOURCE_LIMIT超过了可配置上限(深度、节点数、字符串长度等)。
DOCX_DSL_RENDER_FAILED适配器 instantiate 步骤抛出异常——通常表示 docx.js 不兼容,并通过出错的 dslPath 暴露出来。

HTTP 状态映射(REST)

ClassStatus
编译错误(被校验器捕获)400
运行时错误(渲染时捕获)422
编译阶段超出资源上限400
运行时超出资源上限422
docx.js 本身失败(少见)422(现有的 FAILED_TO_EXPORT_DOCX_FILE 路径)

行为矩阵(REST)

场景HTTPBody code
没有 customNodeDsl 字段200不变的 DOCX 响应
customNodeDsl 有效,所有规则都能渲染200DOCX
缺少 dslVersion400DOCX_DSL_INVALID_SHAPE
dslVersion 错误400DOCX_DSL_UNKNOWN_VERSION
customNodeDsl.nodes 超过 maxRules400DOCX_DSL_RESOURCE_LIMIT
保留的根键(requiresStyles 等)400DOCX_DSL_RESERVED_SHAPE
非法包含关系400DOCX_DSL_INVALID_CONTEXT
未知元素名称400DOCX_DSL_UNKNOWN_ELEMENT
非法 $ref 路径400DOCX_DSL_INVALID_REF
必需属性解析为 undefined,prop schema 拒绝422DOCX_DSL_INVALID_PROP
运行时超过深度上限422DOCX_DSL_RESOURCE_LIMIT

DSL 采用 fail-fast:任何错误都会中止导出,不会产生部分输出。静默的部分输出会生成无效 DOCX,其调试难度比显式错误更高。


安全模型

REST 接口接受来自已认证客户端的任意 customNodeDsl。该 DSL 的设计保证:任何有效输入组合都无法触达 docx.js 内部、宿主文件系统或网络。

ThreatMitigation
任意代码执行不使用 evalFunction、动态导入。采用封闭的 adapter / op / unit / transform 白名单。
访问 PM 节点之外的属性$ref 解析器只遍历 node.*;不支持数组索引,不进行原型链遍历。
通过 __proto__ / prototype / constructor 片段污染原型路径片段守卫会拒绝它们,并返回 DOCX_DSL_INVALID_REF
通过深层嵌套导致 OOM在编译阶段及每次递归调用中强制执行 maxRenderDepth: 32
通过大型载荷导致 OOMmaxRulesmaxRenderNodesmaxStringLengthmaxTemplateLength 上限。
通过表格导致 OOMmaxTableRows: 1024maxTableCellsPerRow: 64
$template 解析中的二次时间复杂度对解析后的长度设置 maxTemplateLength 上限;对操作参数列表设置 maxOpArgs: 32
生成损坏的 docx 输出按元素的 Zod prop schema 在编译期捕获大多数问题;运行时在调用 docx.js 构造函数前进行类型检查。
未经授权的媒体获取DSL 中没有 ImageRun.data 字段;图像数据仅通过内置的 image PM 节点流入。
恶意 URLExternalHyperlink.link 白名单仅允许 httphttpsmailtotel

REST API 补充说明

DSL 通过现有的 POST /v2/convert/export/docx 端点传输,作为现有 styleOverridespageSizepageMarginsheadersfooters 字段的同级字段。有关请求头、响应结构以及其余 body schema,请参见 REST API reference

Body fieldTypeDefaultDescription
customNodeDslObject (CustomNodeDsl)undefined自定义节点渲染规则。

当请求以 multipart/form-data(该端点的表单变体)传入时,customNodeDsl 会作为同名表单字段中的 JSON 字符串化值 发送,就像 styleOverrides 一样。服务会解析它并将结果转发到导出流水线。


故意未包含的内容

这些是明确的边界,不是 bug:

  • 对数组属性的循环。 v1 中没有 $each。该形状被保留(DOCX_DSL_RESERVED_SHAPE),因此以后可以在不破坏任何当前已编译载荷的情况下添加它。在此之前,请通过父级(块)发出可变长度集合,并使用 $children 分发其条目。
  • 跨规则属性访问。 子规则不能读取其父规则的 PM 节点。可行的例外是父规则上的 $children: { as: "inline", marks: "node" },它会将父级的 marks 转发给子 run。
  • 自由形式表达式。 该 DSL 不是图灵完备的,也不嵌入 Jexl / JSONata / eval。所有操作都有类型约束并带上限。
  • 图像数据来源。 图像字节永远不来自 DSL;它们来自内置的 image PM 节点。
  • 文档级样式贡献。 DSL 描述的是 nodes 如何渲染。命名样式仍然位于 styleOverrides 中。保留的 requiresStylescontributedStyles 根键标记了未来的接口。
  • 在不同版本之间编辑 dslVersion 载荷。 dslVersion: "1.0" 是精确字面量。未来的小版本将采用增量方式(新增可选字段、新增操作),并会原样接受 v1.0 载荷。大版本升级将需要显式迁移。

相关