← すべてのドキュメント
執筆ツール

脚注、書式のコピー、大文字と小文字の変換

ほかの商用エディターでは有料プランでしか使えない、長い文書のための 3 つのツールです。番号が自動的に振り直される参照記号と注を備えた脚注、ある選択範囲から別の選択範囲へ文字の書式をコピーする書式のコピー、そして選択範囲の大文字と小文字の変換です。3 つとも標準ライセンスに含まれています。

目次

文書の見出しから作った目次のブロックを、見出しへ移動するリンク付きで挿入し、書いている間ずっと内容を最新に保ちます。見出しの名前を変える、追加する、削除する、セクションを移動するといった操作をしても、一覧は文書から作り直されるので、ずれることはありません。

これは文書のアウトラインパネルと同じものではありません。アウトラインパネルはナビゲーションのためのエディターの UI で、保存する 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 は表示しません。

ページ番号。pagination プラグインを読み込んでページ表示をオンにすると、各項目に見出しがあるページの番号が、印刷された目次のように点線のリーダー付きで付きます。番号はページ表示のオーバーレイが描くのと同じページの境界から求めるので、常に見ているものと一致します。ページ表示以外では新しい番号は計算されませんが(ページ番号は架空のものになってしまうため)、ブロックにすでにある番号は保持されます。そのため、ページ表示で保存した文書を読み込んでも、番号が黙って取り除かれることはありません。更新するには、もう一度ページ表示にしてください。

このブロックは 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" なので、キャレットはそれを 1 つの単位として扱い、番号の中に入ることはありません。参照記号をクリックするとその注に移動し、↩ をクリックすると戻ります。

書式のコピー

キャレットの位置の文字書式を取得し、次の選択範囲に適用します。書式は計算済みのスタイルから読み取るので、元が <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 とは異なります。この CSS プロパティは、その 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 で編集できます。

オートコレクトと文字組み

入力中によくある誤字を修正し、文の先頭を大文字にし、入力した近似の表記を本来の文字に変えます。ストレートの引用符は曲がった引用符に、-- は全角ダッシュ(em ダッシュ)に、... は省略記号に、(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 ドキュメントと同じ逃げ道です。組み込みの単語の一覧は意図的に控えめにしています。実在する単語でもある文字列(ill、lets、its)はそのままにします。実在する単語について推測を誤るほうが、修正を見逃すよりも悪いからです。

差し込みフィールド

データの代わりになるプレースホルダーで、1 つの文書を、多数のレコードに対して出力するテンプレートにできます。差し込み印刷、請求書の一括作成、個別のレター、自動生成する契約書などに使います。各フィールドは分割できない 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 に入ることはありません。これはこの機能で本当に危険な唯一の点です。プレビューをオンにして保存したときに、{{Recipient name}} があった場所に「Ada Lovelace」がテンプレートに書き込まれてしまうと、ユーザーは見ただけでテンプレートを壊してしまいます。そのため、シリアライザーはラップされており、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 の前に来てしまうのは、典型的な誤りです。空のセルは、空白が「最小の値」ではなく「値がない」ことを意味するので、どちらの順序でも末尾に回ります。並べ替えは安定しているので、2 番目の列で並べ替えても、最初の列の順序は保たれます。

結合したセルのある表では、まずい並べ替えをするのではなく、拒否してその理由を伝えます。行が移動すると rowspan は意味を失います。誰かが 1 時間かけて作った表を黙って壊してしまうのは、並べ替えを断るよりもはるかに悪いことです。<thead> と <tfoot> は移動せず、すべて <th> の最初の行は、<thead> がなくても見出しとして扱われます。行番号は位置に基づくので、並べ替えた後は、その時点で一番上にある行が 1 行目になります。

固定ペン

固定の文字書式をオンにすると、それ以降に入力したものすべてにその書式が付きます。元のテキストではないことがひと目でわかる色で文書に注釈を書き込むための、マーカーのようなものです。書式のコピーとは異なり、ペンの出力は本物のコンテンツで、保存する HTML に残ります。

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

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

ひと続きの入力は、キー入力ごとに 1 つの span になるのではなく、ペンが延長し続ける1 つの 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
});

数えるのは段落ではなく、見た目の行です。4 行に折り返される段落には 4 つの番号が付きます。引用する人にとって「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」は簡単に削除できますが、描画のレイヤーの透かしはそうではありません。

印刷するかどうかは画面上の表示とは別の判断なので、透かしは既定では印刷から除外されます。印刷物や PDF のエクスポートにも含めるには、print: true を設定してください。

右から左へのテキストと双方向テキスト

アラビア語、ヘブライ語、ペルシア語、ウルドゥー語は右から左へ書きます。方向は、ブロックごとまたは文書全体に設定することも、コンテンツから検出することも、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() に残ります。保存時に方向が消えてしまう文書は、読めなくなってしまいます。

検出には first-strong ルールを使います。HTML の仕様が dir="auto" について定めているのと同じアルゴリズムで、段落は、数字と句読点を飛ばして、最初の強い方向性を持つ文字の方向になります。"123 שלום" が右から左になり、"123 hello" が左から右になるのはそのためです。また、数字だけの段落は推測せずにそのままにします。

方向を反転すると、明示的な配置も入れ替わります。dir を設定して変わるのは既定の配置だけなので、text-align:left を明示的に設定した段落は、RTL のブロックの中でも左端に固定されたままになり、崩れて見えます。明示的な物理的な配置は左右が反転され、新しい方向の自然な側になる場合は完全に取り除かれるので、その後ブロックは dir に従います。

混在したコンテンツは、マークするだけでなく分離します。RTL の文に LTR の URL を分離せずに入れると、双方向テキストのアルゴリズムが周りの句読点を並べ替えてしまいます。(https://example.com) のかっこが逆側に表示されてしまうのはそのためです。insertIsolated() はそのまとまりを <bdi> で囲むので、そうしたことは起こりません。

リスト、引用ブロック、表は CSS で反転するので、箇条書きの記号や引用のバーは正しい側に表示されます。

エディター自体も反転します。文書の方向だけでは仕事の半分にすぎません。アラビア語やヘブライ語の作成者が、左から右のアプリケーションの中で右から左のテキストを入力すべきではないからです。ツールバー、メニュー、ドロップダウン、ダイアログ、ステータスバーはすべて反転します。

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

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

UI の方向は、コンテンツの方向と厳密に分けられています。インターフェイスを反転しても文書には何も書き込まれず、getHTMLCode() も変わりません。1 つのページに複数のエディターがある場合、影響を受けるのは反転したエディターだけです。アイコンは意図的に反転しません。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 のエクスポーターは 2 つあり、その違いは重要です。html2pdf は html2canvas で文書を描画し、各ページに画像を置きます。ピクセル単位で正確で、見た目のレイアウトそのものが成果物である場合には適した選択です。しかし、結果は文書の画像です。何も選択や検索ができず、スクリーンリーダーには空のファイルに見え、リンクはクリックできずに描かれているだけで、プレーンテキストの 1 ページに数百キロバイトかかります。

exportpdf は、代わりに PDF のファイル形式を直接書き出します。テキストはテキストのままです。埋め込みの不要な 14 の標準 PDF フォントを使うので、PDF 生成で本当に難しい部分であるフォントのサブセット化が不要になり、一般的なページは 15 KB 未満に収まります。ライブラリは読み込まれず、何もアップロードされません。

  • 見出し、太字/斜体/下線/取り消し線、色、フォントサイズ、箇条書きと番号付きリスト、見出し行が繰り返される表、引用ブロック、罫線、画像
  • リンクは、下線付きのテキストではなく、本物の PDF のリンク注釈になります
  • ページのサイズ、向き、余白は文書自体のページ設定から取得されるので、PDF はページ表示や Word へのエクスポートと一致します
  • 明示的な改ページは尊重され、見出しがページの末尾に取り残されることはありません
  • タイトル、作成者、文書の言語はメタデータとして書き込まれます

知っておく価値のある細かな点が 1 つあります。ビューアーはフォント自体のメトリクスを使ってテキストを配置するので、独自の数値で行を折り返す生成ツールではビューアーと食い違い、余白をはみ出してしまいます。このエクスポーターは各文字の幅を測り、ビューアーが従う必要のある /Widths として PDF に書き込むので、レイアウトと描画は構造上同じ数値を使います。pdf.js(Firefox に同梱されているパーサー)で検証済みで、テキストはきれいに抽出でき、リンクは解決され、余白をはみ出すものはありません。

アクセシビリティのためのタグ付け

テキストを選択できることは必要ですが、それだけでは十分ではありません。タグのない PDF は、依然として Section 508 や EN 301 549 の監査に通りません。支援技術は、見出しもリストの構造も表の意味も保証された読み上げ順序もない、平坦な単語の並びを受け取ることになるからです。このエクスポートは完全な構造ツリーを書き出すので、文書は単に読めるだけでなく、ナビゲートできるものになります。

  • H1–H6 は見出し、P は段落、BlockQuote は引用
  • L / LI / Lbl / LBody。これにより、スクリーンリーダーはばらばらの 3 つの段落ではなく、「リスト、3 項目」と読み上げます
  • Table / TR / TH / TD with /Scope を付けた見出しのセル。「Revenue, 1,240」と聞こえるか、単に「1,240」と聞こえるかの違いです
  • Figure 要素には、画像の代替テキストから得た /Alt が付きます
  • 罫線、セルの枠線、引用のバー、柱(ヘッダー/フッター)はアーティファクトとしてマークされ、読み上げ順序から除外されます。ページごとに繰り返されるヘッダーが読み上げられるのは、監査でもっともよく指摘される問題の 1 つです
  • /MarkInfo, /StructTreeRoot, a /ParentTree, /Lang, and /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 パッケージを書き出します。ZIP は、インポーターが使う DecompressionStream と対になる CompressionStream で作ります。ライブラリもアップロードもありません。

エクスポートは、インポーターが読み戻すものとまったく同じもの(同じハイパーリンクのリレーションシップ、番号付けの id、脚注とコメントのパート、リビジョンの規約)を意図的に出力するので、HTML → .docx → HTML は、たまたまファイルの拡張子が同じ 2 つの機能ではなく、本当の往復になります。両方向で検証しています。エディターは自身の出力を再インポートでき(見出し、インラインの書式、色、リンクの URL、入れ子のリスト、表、引用、脚注、変更履歴、数式がすべて復元されます)、パッケージ自体もパートごとに検証しています。すべての XML のパートが整形式であること、すべての r:id が解決されること、すべての numId が定義されていること、すべてのパートが [Content_Types].xml で宣言されていることです。

手作りの .docx ファイルを知らないうちに壊してしまう 2 つの細かな点にも、ここでは対応しています。w:sz は半ポイント単位なので、ポイントで書くと文書全体が半分のサイズで表示されます。また、パートのどこかに制御文字が 1 つでもあると、Word はその文字をスキップするのではなく、ファイルそのものを拒否します。

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

コンテンツのサニタイザー

「エディターの中では何も実行されない」は、基準として誤っています。基準は、エディターが保存したものを表示するアプリケーションの中で何も実行されないことです。どのデプロイもその形をしているからです。あるユーザーがコンテンツを書き、それが保存され、別のユーザーのブラウザーがそれを表示します。

エディターのコアは、すでにインラインのイベントハンドラーを取り除き、リンクとフォームの action の javascript: を拒否しています。20 種類のペイロードを試したテストでも、エディターの中で実行されたものはありませんでした。しかし、3 つの構造が保存された出力に残り、そのうちの 1 つである <iframe srcdoc> は、保存された HTML を通常のコンテナーで表示した途端に実行されました。サニタイザーはそれをふさぎます。

  • ブロックリストではなく許可リスト — ブロックリストは、HTML に今後追加されるすべての要素まで考慮済みであると約束するようなものです。未知の要素はラッパーを外すので、ユーザーがその中に入力したテキストが失われることはありません。
  • 入るとき(設定、貼り付け、ドロップ)と出るときの両方で実行されます。確実なのは出力の経路です。保存されるのはそれであり、切り離したコピーに対して動作するのでキャレットを乱すことはなく、プラグインより前からあるコンテンツも次の保存時にきれいにされます。
  • URL は制御文字を取り除いた後にチェックします。ブラウザーは java&#9;script: を javascript: として解析するので、生の文字列をテストするだけでは簡単に回避されてしまいます。
  • style は断片単位ではなく宣言単位でフィルタリングします。url(javascript:f()) から javascript: を削除すると url(f()) が残り、それが無害な相対 URL として読まれて、まさにそれを捕らえるためのチェックを通過してしまうからです。
  • mutation XSS(1 回目の解析では無害でも、シリアライザーが書き換えた後に危険になるマークアップ)を防ぐため、安定するまで解析を繰り返します。
  • target="_blank" のリンクには rel="noopener" が付きます。

埋め込みは引き続き使えます。https のソースの <iframe> は残ります。ホストの許可リストを設定していない場合は、動画の埋め込みに必要なものは許可し、トップレベルのナビゲーション、ポップアップ、モーダル、フォームは許可しない sandbox が付けられます。挿入されたフレームは親をスクリプトで操作できませんが、そうしなければトップのウィンドウを移動させることはでき、それはフィッシングの手口だからです。より強力な制御は、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
3 つとも基本ライセンスに含まれています

CKEditor 5 では、脚注は Essential、Professional、Custom の各プランでしか使えず、目次、書式のコピー、大文字と小文字の変換、差し込みフィールドは「selected CKEditor Plans」でしか使えません。TinyMCE は、これらをすべて「only available for paid TinyMCE subscriptions」のプレミアムプラグインとして提供しており、オートコレクトと文字組みは別々の 2 つのプラグインです。ここでは、すべてが $129 の永久ライセンスに含まれています(公平のために述べておくと、CKEditor の自動テキスト変換はオープンソースのコアに含まれているので、オートコレクトについては差ではなく同等です)。詳しい比較を参照してください。