← 全部文档
创作工具

脚注、格式刷与更改大小写

三种用于长文档的工具,其他商业编辑器都把它们放在付费方案中。脚注的标记和注释会自动重新编号;格式刷可以把字符格式从一处选区复制到另一处;更改大小写作用于选区。三者都包含在标准授权中。

目录

插入一个根据文档标题生成的目录块,其中的链接可以跳转到对应标题,并在您写作时保持同步。重命名标题、添加标题、删除标题、移动章节——列表都会根据文档重新生成,因此不会与内容脱节。

这与文档大纲面板不是一回事。大纲面板是用于导航的编辑器界面装饰,永远不会出现在保存的 HTML 中。目录则是您发布或打印的文档的一部分,因此它会保留在 getHTMLCode() 中,并且无需运行 JavaScript 也能正确阅读。为什么把这条界线划错会导致目录块过时或面板无法使用,请参阅目录块是内容,大纲面板不是。

editor.insertTableOfContents();   // one per document
editor.updateTableOfContents();   // force a rebuild (normally automatic)
editor.hasTableOfContents();      // -> true | false
editor.removeTableOfContents();
editor.getTableOfContents();
// -> [{ id: "rte-h-3k1a0", level: 1, text: "Master Agreement", page: 1 }, ...]
配置项默认值作用
tocTitle"Contents"列表上方的标题。设置为 "" 可省略。
tocMinLevel / tocMaxLevel1 / 3包含的标题级别(含两端)。缩进随级别变化。
tocPageNumbers"auto""auto" 在页面视图开启时显示页码,true 总是尝试显示,false 从不显示。

页码。加载了分页插件并开启页面视图时,每个条目都会显示其标题所在的页码,并像印刷目录那样用点线引导。这些页码与页面视图叠加层所绘制的分页边界来自同一来源,因此总是与您看到的一致。在页面视图之外不会计算新的页码——那样的页码是虚构的——但目录块中已有的页码会保留,因此加载一份在页面视图中保存的文档时,页码永远不会被悄悄去掉。重新进入页面视图即可刷新。

该块是 contenteditable="false",并根据标题重新生成,因此不能手动编辑——要移动它,请把它作为一个整体删除后重新插入。生成区域(目录块本身、脚注部分)中的标题会被跳过,因此它永远不会列出自己的标题。

脚注

在光标处插入一个带编号的引用标记,并在文档末尾的脚注部分插入对应的注释,然后让两者保持同步。编号遵循文档顺序:在已有脚注上方添加一个脚注、删除一个标记或移动一个段落,所有标记和注释都会重新编号,注释列表也会重新排序以保持一致。删除标记会同时删除其注释,删除最后一个标记会删除整个脚注部分——保存的 HTML 中不会留下孤立的内容。

<script src="richtexteditor/rte.js"></script>
<script src="richtexteditor/plugins/all_plugins.js"></script>

<script>
  var editor = new RichTextEditor("#editor");

  editor.insertFootnote();                 // marker at the caret, caret moves into the note
  editor.insertFootnote("Ibid., p. 42");   // ...or supply the note text up front

  editor.getFootnotes();
  // -> [{ id: "fn3k1a0", number: 1, text: "Ibid., p. 42" }]

  editor.syncFootnotes();      // force a renumber (normally automatic)
  editor.getFootnotesCss();    // the stylesheet, to reuse on your public page
</script>
配置项默认值作用
footnotesTitle"Footnotes"注释部分上方的标题。设置为 "" 可省略。
footnotesNumbering"decimal"注释的列表样式:decimal、lower-roman、upper-roman、lower-alpha、upper-alpha。
footnotesMarkerPrefix""包裹标记编号的前缀,例如 "[" 会渲染为 [1]。
footnotesMarkerSuffix""上一项的后半部分,例如 "]"。

HTML 中会出现什么

脚注是内容,因此与页面视图叠加层不同,它们应当保留在 getHTMLCode() 中。编号以字面文本写入,而不是由 CSS 计数器绘制,因此保存的 HTML 是自包含的:发布它、导出它,或者在一个从不加载我们样式表的页面上渲染它,编号依然存在并且正确。

<p>The clause was struck out<sup class="rte-fn-ref" data-fn-id="fn3k1a0"
     data-fn-number="1" id="fnref-fn3k1a0" contenteditable="false">1</sup>.</p>

<section class="rte-footnotes">
  <h2 class="rte-fn-title">Footnotes</h2>
  <ol class="rte-fn-list" data-numbering="decimal">
    <li class="rte-fn-note" data-fn-id="fn3k1a0" data-fn-number="1" id="fn-fn3k1a0">Ibid., p. 42<a
      class="rte-fn-back" href="#fnref-fn3k1a0" contenteditable="false">&#8617;</a></li>
  </ol>
</section>

标记是 contenteditable="false",因此光标会把它们当作一个整体,无法落到编号内部。点击标记会跳转到其注释;点击 ↩ 则返回。

格式刷

获取光标处的字符格式,并把它刷到下一个选区上。格式是从计算样式中读取的,因此无论来源是 <b>、style="font-weight:700" 还是 CSS 类都没有关系。刷格式时还会去除目标中已有的字符格式——正因为如此,把无格式文本刷到粗体文本上才能真正生效。

editor.copyFormatting();       // capture at the caret
editor.pasteFormatting();      // apply to the current selection

editor.toggleFormatPainter();      // one-shot: capture, then paint the next selection
editor.toggleFormatPainter(true);  // sticky: keep painting until cancelled
editor.cancelFormatPainter();      // or press Escape
editor.isFormatPainterActive();    // -> true | false
editor.getCapturedFormatting();    // -> { "font-weight": "700", "color": "rgb(204, 0, 0)" }

只有与编辑器基准样式确实不同的属性才会被带过去,因此刷一次格式只会生成一个简短的 style 属性,而不是一大堆继承来的默认值。对同一段文本重复刷格式不会嵌套包装元素。可以通过 formatPainterProperties 配置哪些属性会被复制;对齐和外边距等块级属性被有意排除在外,因为一个会让文档重新排版的格式刷并不是这个工具的用途。

更改大小写

改写选区中的实际字符——而不是使用 text-transform,后者只在 HTML 被用到别处之前看起来正确。内联标记会保留:更改 the <strong>Data</strong> Act 的大小写后,<strong> 仍然存在。没有选中任何内容时,它作用于光标所在的单词。

editor.changeCase("upper");      // THE DATA ACT
editor.changeCase("lower");      // the data act
editor.changeCase("title");      // The Data Act
editor.changeCase("sentence");   // The data act
editor.changeCase("toggle");     // tHE dATA aCT

editor.getChangeCaseModes();     // -> ["upper","lower","title","sentence","toggle"]

标题式大小写(Title Case)会让短小的单词(a, an, and, of, the, to……)保持小写,除非它们位于开头或结尾,并且在连字符复合词内部也应用同样的规则——因此 state-of-the-art 会变成 State-of-the-Art,而不是 State-Of-The-Art。可以用 titleCaseSmallWords 编辑这个列表。

自动更正与排版

在您输入时修正常见的拼写错误,把句子开头的字母大写,并把输入的近似写法转换为真正的字符:直引号变成弯引号,-- 变成长破折号,... 变成省略号,(c) 变成 ©,1/2 变成 ½。

editor.setAutocorrectEnabled(false);   // off at runtime
editor.isAutocorrectEnabled();

editor.addAutocorrectRule("acheive", "achieve");
editor.getAutocorrectRules();

// Run the same typography normalisation over imported or pasted text:
editor.applyTypography('He said "hello" -- it is 1/2 done...');
// -> 'He said “hello” — it is ½ done…'
配置项默认值作用
autocorrectEnabledtrue总开关。
autocorrectSpellingtrue常见拼写错误表(teh → the)。
autocorrectCapitalizetrue把句子的第一个单词首字母大写。
autocorrectTypographytrue智能引号、破折号、省略号、箭头、符号、分数。
autocorrectReplacementsnull额外的规则,与默认规则合并。把一个单词映射为它自身即可禁用某条内置规则。

代码中不会触发任何更正。<code>、<pre>、<kbd>、<samp> 和 <var> 会被完全跳过,任何标记了 data-rte-no-autocorrect 的子树也一样。把代码示例中的引号变成弯引号会破坏代码,这是此类功能可能造成的最大危害。

更正之后立即按 Backspace 即可撤销,保留您原本输入的内容——这与 Word 和 Google Docs 提供的退路相同。内置的单词表有意保持保守:同时也是真实单词的字符串(ill、lets、its)不会被改动,因为把一个真实单词猜错,比漏掉一次更正更糟糕。

合并字段

用占位符代表数据,这样一份文档就成了可以针对多条记录进行渲染的模板——邮件合并、批量开票、个性化信函、自动生成的合同。每个字段都是一个原子的 contenteditable="false" 标签,因此它永远不会被删掉一半变成 {{customer.na}},从而悄悄地无法解析。

var editor = new RichTextEditor("#editor", {
  mergeFields: [
    { id: "recipient.name",  label: "Recipient name", sample: "Ada Lovelace" },
    { id: "invoice.total",   label: "Invoice total",  sample: "£1,240.00" },
    { id: "date.today",      label: "Today's date",   sample: function () { return new Date().toLocaleDateString(); } }
  ]
});

editor.insertMergeField("recipient.name");
editor.listMergeFields();
// -> [{ id: "recipient.name", label: "Recipient name", known: true, count: 1 }]

editor.setMergeFieldPreview(true);    // show sample data instead of labels
editor.toggleMergeFieldPreview();
editor.isMergeFieldPreview();

// Render one record. Flat or nested data both work.
editor.renderMergeFields({ "recipient.name": "Grace Hopper", invoice: { total: "£90" } });
// -> HTML with the values substituted. The document itself is untouched.
配置项默认值作用
mergeFields示例集合{ id, label, sample } 定义。随附的列表只是示例——请替换为您自己的数据结构。
mergeFieldPrefix / Suffix"{{" / "}}"绘制在标签两侧的包裹符号,让占位符一目了然。
mergeFieldMissing"placeholder"当数据中没有值时 renderMergeFields 的处理方式:保留可见的占位符,或者设为 "empty" 将其移除。

预览永远不会进入保存的 HTML。这是该功能唯一真正危险的地方:如果开启预览后按下保存,就把 “Ada Lovelace” 写进了模板中原来 {{Recipient name}} 的位置,那么用户仅仅是看了一眼就毁掉了自己的模板。因此序列化方法都经过了包装——getHTMLCode、getJSON、getHTMLContent 和 getText 在调用期间都会把每个标签恢复为其标签名。在预览时保存,得到的仍然是模板。

基于同样的原则,renderMergeFields() 在一个分离的副本上工作:生成合并后的输出永远不会修改正在编辑的文档。没有匹配数据的字段会保留其占位符,而不是渲染为空白,因为一行悄悄变空的发票比一行明显未填写的发票更糟糕。从别处构建的模板中粘贴过来的字段会以 known: false 报告,而不是被丢弃,这样您就可以告诉用户他们的数据需要提供什么。

表格排序与行编号

按光标所在的列对表格排序,并添加一个在每次排序后自动重新编号的编号列。

editor.sortTableAtCaret("asc");        // or "desc"
editor.sortTableByColumn(2, "desc");
// -> { ok: true, sortedBy: 2, direction: "desc", detectedType: "number" }

editor.canSortTable();                 // -> { ok: false, reason: "...merged cells..." }

editor.addTableRowNumbers({ series: "upper-roman" });
editor.removeTableRowNumbers();
editor.hasTableRowNumbers();

列的类型是检测出来的,而不是假定的。一列价格即使单元格内容是 $1,240.00 或 (500)(会计记法中的负数),也会按数值排序;一列日期会按时间先后而不是字母顺序排序。把数值列按文本排序,就会出现 10 排在 9 前面的经典错误。无论升序还是降序,空单元格都会沉到底部,因为空白表示“没有值”,而不是“最小的值”。排序是稳定的,因此按第二列排序时会保留第一列的顺序。

对于有合并单元格的表格,它会拒绝排序并说明原因,而不是把它们排得一团糟。行移动之后,rowspan 就失去了意义;悄悄地把某人花了一个小时制作的表格搞乱,远比拒绝排序糟糕得多。<thead> 和 <tfoot> 永远不会移动,即使没有 <thead>,全部由 <th> 组成的第一行也会被视为表头。行编号是按位置计算的,因此排序后第 1 行就是现在排在最上面的那一行。

固定画笔

开启一种固定的字符格式后,此后输入的所有内容都会带上它——就像拿起一支记号笔,用一种显然不同于原文的颜色批注文档。与格式刷不同,画笔的输出是真正的内容,会保留在您保存的 HTML 中。

editor.setPermanentPen(true);
editor.togglePermanentPen();
editor.isPermanentPenActive();

editor.setPermanentPenStyle({ "color": "#0b6", "font-style": "italic" });
editor.getPermanentPenStyle();

一整笔会进入同一个由画笔不断延伸的 span,而不是每次按键一个 span。移动光标会结束这一笔,因此下一段文字会有自己的 span,而不会跳到上一段中。关闭画笔时,光标会移出这段文字——否则输入会继续发生在带样式的 span 内部,看起来就像画笔无视了关闭操作。

交叉引用

“参见第 3.2 条”“图 4”“如工作范围中所述”——在文档周围被编辑之后,这些引用仍然指向正确的对象。这是让其他文档工具协同工作的关键一环:法律文书编号会给条款重新编号,目录块会重新生成,但一句“参见第 3.2 条”会一直保持不变,除非有东西去更新它。在合同中,过时的引用会改变文档的含义。

editor.listCrossReferenceTargets();
// -> [{ id, type: "heading"|"clause"|"footnote"|"table"|"figure", label, number }]

editor.insertCrossReference(targetId, "label");   // "clause 3.2" / "Table 2"
editor.insertCrossReference(targetId, "text");    // the heading's own text
editor.insertCrossReference(targetId, "number");  // "3.2"
editor.insertCrossReference(targetId, "page");    // page number, in page view
editor.insertCrossReference(targetId, "position");// "above" / "below"

editor.updateCrossReferences();
editor.getCrossReferences();

条款编号根据在列表中的位置推导,与渲染编号的 CSS 计数器方式相同,因此在上方插入一个条款,会让下方的每个引用都重新编号。标题引用会跟随被重命名的标题。表格和图会自动编号;如果有 <caption> / <figcaption>,则使用它们。

目标被删除的引用会以可见的方式失效——它会以红色渲染 [reference not found],而不是保留其最后的值。Word 也是这样做的,原因相同:一个看似合理却错误的条款编号,远比一个明显损坏的引用危险。显示的文本是保存的 HTML 中的真实文本,因此文档离开编辑器之后,引用仍然能正确阅读。

页边行号

在左侧页边为每一行编号,就像文字处理软件为诉状、法规、笔录以及任何需要按行引用的内容(“第 4 页,第 12–15 行”)所做的那样。在一些司法辖区,法院的提交规则要求行号,这就是为什么文档编辑器有这项功能,而浏览器编辑器通常没有。

editor.setLineNumbers(true);
editor.toggleLineNumbers();
editor.isLineNumbers();
editor.getLineCount();          // visual lines, not blocks

editor.setLineNumberOptions({
  restart:  "page",   // "continuous" | "page" (needs page view) | "block"
  interval: 5,        // show every 5th number
  start:    1,
  gutter:   38        // gutter width in px
});

它统计的是视觉行,而不是段落。一个折成四行的段落会得到四个编号,因为对引用者来说,“第 12 行”就是这个意思。行框通过 Range.getClientRects() 获取,因此计数遵循实际的渲染布局——改变窗口宽度,编号也会随文本重新排列。

设置 restart: "page" 并开启页面视图时,编号会在每一页的顶部重新开始,这正是提交规则所规定的。与页面视图叠加层本身一样,页边纯粹是显示层面的:它是 contenteditable="false",并在每次序列化时被去除,因此无论行号开启与否,您保存的 HTML 都逐字节相同——包括页边的内边距,否则它会以内联样式的形式泄漏到可编辑区域上。

格式标记

相当于 Word 的 按钮:段落标记、块轮廓,以及真正有用的部分——高亮显示那些不可见却并非无害的字符。不换行空格、零宽空格、软连字符和双向文字标记都会随粘贴的内容一起进来,造成谁也看不见的布局问题。

editor.setFormattingMarks(true);
editor.toggleFormattingMarks();
editor.isFormattingMarks();

// config
formattingMarkPilcrow:    true,   // ¶ at the end of each block
formattingMarkBlocks:     true,   // dashed outline around block elements
formattingMarkInvisibles: true    // nbsp / zero-width / soft hyphen / bidi

段落标记和轮廓完全由 CSS 绘制,因此无论如何都不会改变文档。CSS 无法触及不可见字符,因此这些字符会被包装——但每个包装元素都会在序列化时被去除,并在关闭该功能时被移除,恢复的是原始字符,而不是标签符号。在 <code> 或 <pre> 中永远不会应用标记,因为那里的字符可能是有意为之。

公平地说:这一项是功能对等,而不是优势。TinyMCE 在其免费核心中提供了 visualchars 和 visualblocks,CKEditor 也有 show-blocks。

水印

在内容后面绘制 DRAFT、CONFIDENTIAL 或客户名称,让读者一眼就能看出不应把文档视为最终版本,或者不应传播它。

editor.setWatermark(true);
editor.toggleWatermark();
editor.isWatermark();

editor.setWatermarkOptions({
  text:     "CONFIDENTIAL",
  mode:     "tile",     // "tile" | "single"
  opacity:  0.18,
  angle:    -30,
  fontSize: 48,
  color:    "#94a3b8",
  print:    false,      // opt in to include it when printing / exporting PDF
  image:    null        // data URI or URL; overrides the text
});

它永远不会成为您内容的一部分。水印以 SVG 背景的形式渲染,因此无法被选中、复制或通过查找替换找到,并且每次序列化时都会被去除。这不只是为了整洁:写入文档的水印,会在草稿早已不再是草稿之后仍留在发布的页面上;而作为内容的 “CONFIDENTIAL” 很容易被删除,渲染层的水印则不然。

打印与屏幕上的标记是两个独立的决定,因此水印默认不会打印。设置 print: true 可以让它出现在打印输出和 PDF 导出中。

从右到左与双向文本

阿拉伯语、希伯来语、波斯语和乌尔都语从右向左书写。方向可以按块或为整个文档设置,可以根据内容检测,也可以通过 dir="auto" 交给浏览器决定。

editor.setTextDirection("rtl");     // the block(s) in the selection
editor.toggleTextDirection();
editor.getTextDirection();          // -> "rtl" | "ltr"

editor.setBaseDirection("rtl");     // the whole document
editor.getBaseDirection();

editor.detectTextDirection("123 שלום");   // -> "rtl"  (first-strong)
editor.autoDetectTextDirection();          // stamp every block from its own text

// Mixed content: isolate a run so the surrounding direction cannot reorder it
editor.isolateBidi("https://example.com", "ltr");   // -> <bdi dir="ltr">…</bdi>
editor.insertIsolated("https://example.com", "ltr");

方向属于内容。与页面视图叠加层或水印不同,dir 会写入文档并保留在 getHTMLCode() 中——一份在保存时丢失了方向的文档将无法阅读。

检测采用“第一个强字符”规则,这与 HTML 规范为 dir="auto" 定义的算法相同:段落采用其第一个具有强方向性的字符的方向,跳过数字和标点。这就是为什么 "123 שלום" 是从右到左,而 "123 hello" 是从左到右,也是为什么只有数字的段落会保持原样,而不会被猜测。

翻转方向时会交换显式的对齐方式。设置 dir 只会改变默认对齐方式,因此一个显式设置了 text-align:left 的段落在 RTL 块中仍会固定在左边缘,看起来就像出了问题。显式的物理对齐会被镜像;如果镜像后正好落在新方向的自然一侧,则会被完全去除,使该块此后遵循 dir。

混合内容会被隔离,而不只是被标记。如果把一个 LTR 的 URL 放进 RTL 句子而不加隔离,双向文字算法会重新排列周围的标点——(https://example.com) 的括号就是这样被渲染到错误的两侧的。insertIsolated() 会用 <bdi> 包装这段文字,杜绝这种情况。

列表、引用块和表格会通过 CSS 翻转,使项目符号和引用竖线位于正确的一侧。

编辑器本身也会镜像。文档方向只完成了一半工作——阿拉伯语或希伯来语作者不应该在一个从左到右的应用中输入从右到左的文本。工具栏、菜单、下拉菜单、对话框和状态栏都会翻转:

editor.setRtlUserInterface(true);
editor.toggleRtlUserInterface();
editor.isRtlUserInterface();

// config: rtlUserInterface — "auto" (follow the document), true, or false

界面方向与内容方向严格分离:镜像界面不会向文档写入任何内容,getHTMLCode() 保持不变。如果一个页面上有多个编辑器,只有被镜像的那一个受影响。图标被有意不翻转——Word 也不翻转其工具栏图标,粗体/斜体/对齐的符号在两个方向上含义相同。只有体现阅读顺序的图标(增加缩进、减少缩进、菜单箭头)才会镜像。

公平地说,CKEditor 和 TinyMCE 在其免费核心中都提供了基本的文本方向功能;这里的自动检测、双向隔离和界面镜像超出了这一基础水平,但基础功能本身并不是它们收费的项目。

拖动手柄与块重新排序

把鼠标悬停在任意段落、标题、列表、表格或提示框上,旁边就会出现一个 ⠿ 手柄——拖动它即可移动整个块,插入线会准确显示它将落在哪里。这是 Notion 推广开来的块重新排序交互,移动条款时比剪切粘贴好用得多。

// The drag is a thin layer over a scriptable API:
editor.moveBlockUp();               // block containing the caret
editor.moveBlockDown();
editor.moveBlock(el, beforeEl);     // programmatic move

editor.setDragHandleEnabled(false); // hide the grip
// config: dragHandleEnabled, dragHandleKeyboard

Alt+Shift+↑/↓ 可以移动光标所在的块——与 Word 移动段落所用的按键相同。这不是附赠的快捷键:只能用指针操作的重新排序功能无法通过键盘使用,因此正是键盘路径让这项功能变得可访问。在两种方式中,光标都会随被移动的块一起移动。

手柄和插入线完全位于可编辑区域之外,因此从结构上就不可能出现在您保存的 HTML 中。拖动使用的是普通的鼠标事件,而不是原生的 HTML5 拖放——在 contenteditable 中,原生拖放是一种在哪里都只能半工作的浏览器行为:它会启动文本拖动、绘制错误的拖影,并放下序列化后的 HTML,而不是移动节点。在 RTL 文档中,手柄出现在右侧,也就是行的起始处。

包含真实文本的 PDF 导出

有两种 PDF 导出程序,两者的区别很重要。html2pdf 用 html2canvas 渲染文档,并在每一页放一张图像:像素级准确,当精确的视觉布局就是交付物时,它是正确的选择。但结果只是一份文档的图片——没有任何内容可以选中或搜索,屏幕阅读器看到的是一个空文件,链接只是画上去的而不能点击,而且一页纯文本就要占用数百 KB。

exportpdf 则直接写入 PDF 文件格式。文本仍然是文本。它使用 14 种标准 PDF 字体,这些字体无需嵌入——这就省去了字体子集化这一 PDF 生成器中真正困难的部分,并让一页典型内容保持在 15 KB 以下。不加载任何库,也不上传任何内容。

  • 标题、粗体/斜体/下划线/删除线、颜色、字号、项目符号和编号列表、带重复表头行的表格、引用块、分隔线和图片
  • 链接会变成真正的 PDF 链接注释,而不是带下划线的文字
  • 页面大小、方向和页边距取自文档自身的页面设置,因此 PDF 与页面视图和 Word 导出一致
  • 遵循显式分页符,并且标题永远不会孤零零地留在页面底部
  • 标题、作者和文档语言会作为元数据写入

有一个值得了解的细节:查看器会使用字体自身的度量值来排布文本,因此用自己的数字来折行的生成器会与查看器不一致,导致文字超出页边距。这个导出程序会测量每个字符,并把这些宽度作为 /Widths 写入 PDF,而查看器必须遵循它——这样布局和渲染从设计上就使用相同的数字。已针对 pdf.js(Firefox 所附带的解析器)验证:文本可以干净地提取,链接可以解析,没有任何内容越过页边距。

为无障碍添加标签

可选中的文本是必要条件,但并不充分。未加标签的 PDF 仍然无法通过 Section 508 或 EN 301 549 审核:辅助技术收到的是一串平铺的文字,没有标题、没有列表结构、没有表格语义,也无法保证阅读顺序。导出程序会写入完整的结构树,因此文档是可导航的,而不只是可阅读的。

  • 标题使用 H1–H6,段落使用 P,引文使用 BlockQuote
  • L / LI / Lbl / LBody,这样屏幕阅读器会读出“列表,3 项”,而不是三个松散的段落
  • Table / TR / TH / TD,表头单元格带有 /Scope——这就是听到“Revenue, 1,240”与只听到“1,240”之间的区别
  • Figure 元素带有取自图片替代文本的 /Alt
  • 分隔线、单元格边框、引用竖线以及页眉/页脚被标记为伪影,从而被排除在阅读顺序之外——在每一页上都被朗读一遍的重复页眉,是最常见的审核问题之一
  • /MarkInfo、/StructTreeRoot、/ParentTree、/Lang 和 /DisplayDocTitle,让查看器读出文档标题而不是文件名

结构树的验证方式是像辅助技术那样通过 pdf.js 把它读回来,而不是检查字节:25 条断言覆盖了每一种角色、文档阅读顺序,以及内容树中不存在伪影。

editor.exportToPdf("quarterly-report");
const bytes = editor.getPdfBytes({ title: "Q3", author: "Finance" });

// config
pdfExportFont: "helvetica" | "times" | "courier"
pdfExportBaseFontSize: 11
pdfExportHeader: "Confidential"
pdfExportFooter: "Page {page} of {total}"

在浏览器中导出真正的 .docx

导入功能早已能在客户端读取真正的 OOXML 包,导出却不行:wordexport 把 HTML 包装在一个 MSO 风格的容器中,并命名为 .doc。Word 可以打开它,但它不是 .docx——往返效果很差,Word 保存时会不断提示,有些企业策略还会直接拦截带有 doc 扩展名的 HTML。exportdocx 则写入真正的 OOXML 包:用 CompressionStream 构建 ZIP,与导入程序使用的 DecompressionStream 正好相反。不使用任何库,也不上传任何内容。

它有意输出导入程序能读回的完全相同的内容——相同的超链接关系、编号 id、脚注和批注部件以及修订约定——因此 HTML → .docx → HTML 是真正的往返,而不是两个碰巧共用一个文件扩展名的功能。已经从两个方向进行了验证:编辑器能重新导入自己的输出(标题、内联格式、颜色、链接 URL、嵌套列表、表格、引用、脚注、修订和公式全部还原),并且软件包本身经过了逐个部件的验证——每个 XML 部件格式良好,每个 r:id 都能解析,每个 numId 都有定义,每个部件都在 [Content_Types].xml 中声明。

有两个会悄悄破坏手工构建的 .docx 文件的细节,这里都已处理:w:sz 以半磅为单位,因此写入磅值会让整个文档以一半的大小渲染;而某个部件中任何位置的一个控制字符,都会让 Word 直接拒绝该文件,而不是跳过这个字符。

await editor.exportToDocx("quarterly-report");
const blob = await editor.getDocxBlob({ title: "Q3", author: "Finance" });

内容净化程序

“编辑器中不会执行任何内容”这个标准是错误的。正确的标准是:在渲染编辑器所保存内容的应用中,不会执行任何内容——因为每个部署都是这种形态:一个用户撰写内容,内容被存储,然后另一个用户的浏览器渲染它。

编辑器核心已经会去除内联事件处理程序,并拒绝链接和表单 action 中的 javascript:,一组 20 个攻击载荷在编辑器中都没有被执行。但有三种结构保留到了保存的输出中,其中之一——<iframe srcdoc>——在保存的 HTML 被渲染到一个普通容器中时就立即运行了。净化程序封堵了这个漏洞。

  • 使用允许列表,而不是阻止列表——阻止列表相当于承诺已经想到了 HTML 将来会新增的每一种元素。未知元素会被解包,因此用户在其中输入的文字永远不会丢失。
  • 在内容进入时(设置、粘贴、拖放)和输出时都会运行。输出是有保证的路径:它就是被存储的内容,它在分离的副本上工作,因此永远不会干扰光标,而且插件出现之前的内容会在下一次保存时被清理。
  • URL 在去除控制字符之后才进行检查——浏览器会把 java&#9;script: 解析为 javascript:,因此只检测原始字符串很容易被绕过。
  • style 按声明而不是按片段过滤:从 url(javascript:f()) 中删除 javascript: 会留下 url(f()),它看起来就像一个无害的相对 URL,恰好能通过本应拦截它的检查。
  • 反复解析直到结果稳定,以挫败变异型 XSS——在一次解析中无害、但被序列化程序改写后就变得危险的标记。
  • target="_blank" 链接会加上 rel="noopener"。

嵌入内容仍然可用:https 来源的 <iframe> 会被保留。在没有配置主机允许列表时,它会获得一个沙盒,允许视频嵌入所需的功能,同时禁止顶层导航、弹出窗口、模态对话框和表单——被注入的框架无法对父页面执行脚本,但如果不加限制,它可以让顶层窗口跳转,这是一种钓鱼手段。设置 sanitizerAllowedIframeHosts 是更强的控制方式。

这是纵深防御,不能代替服务器端验证。它在浏览器中运行,而在浏览器中运行的任何东西,都可能被一个干脆不运行它的客户端绕过。请在存储之前在服务器上进行净化,并在输出时进行转义。这一层保护的是正常路径,并收窄了恶意路径;它并不能让不可信的 HTML 无条件地安全渲染。

contentSanitizer: true                       // default
sanitizerAllowedIframeHosts: ["www.youtube.com"]
sanitizerAllowStyleTags: false               // default
sanitizerAllowTags: ["my-element"]
sanitizerAllowAttributes: ["my-attr"]

editor.sanitizeHtml(html)        // clean a string yourself
editor.getSanitizerReport()      // what has been stripped so far
三者都包含在基础授权中

CKEditor 5 把脚注放在其 Essential、Professional 和 Custom 方案中,并把目录、格式刷、大小写转换和合并字段放在 “selected CKEditor Plans”(部分 CKEditor 方案)中。TinyMCE 把这些功能全部作为高级插件提供,“only available for paid TinyMCE subscriptions”(仅限付费 TinyMCE 订阅使用)——自动更正和排版还是两个独立的插件。在这里,它们全部包含在 $129 的永久授权中。(公平地说:CKEditor 的自动文本转换确实包含在其开源核心中,因此在自动更正方面双方是对等的,而不是差距。)请参阅完整对比。