正規表現を直してjsdomで絵文字を扱えるようにした

正規表現を直してjsdomで絵文字を扱えるようにした





Helpfeel Advent Calendar 2022の3日目の記事は、Helpfeelエンジニアであるhata6502が技術的な話をお送りします。 Node.jsでSVGを扱うときに見つけた不具合について、原因調査から修正方法まで深く紹介していきます。

先日、jsdomのDOMParserを使ってSVGの読み込みをしようとしたとき、以下のようなエラーに遭遇しました。

[DOMException InvalidStateError: Failed to serialize XML: text node data is not well-formed.]

エラーを再現できる環境とコードは以下のとおり。

Node.js v16.18.1
jsdom v20.0.2
const { JSDOM } = await import("jsdom");
const { window } = new JSDOM("");

const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
<text x="0" y="0">
🔍Search
</text>
</svg>
`;

new window.DOMParser().parseFromString(svg, "image/svg+xml")
.documentElement.innerHTML;
絵文字「🔍」を取り除くと、エラーが出なくなります。 この不具合を修正しました。

issue
✅ Closed Can't get innerHTML of XML document with emoji · Issue #3461 · jsdom/jsdom
プルリクエスト
✅ Merged Allow emoji characters as well-formed XML by hata6502 · Pull Request #27 · jsdom/w3c-xmlserializer
不具合が修正されたリリース
Release Version 20.0.3 · jsdom/jsdom
1行の正規表現を直してテストを書いただけなので、プルリクエストを作ってから2週間もかからずにマージしてもらえました。

const XML_CHAR = /^(\x09|\x0A|\x0D|\x20-\uD7FF|\uE000-\uFFFD|
-(?:\uD800-\uDBFF\uDC00-\uDFFF))*$/u;
+\u{10000}-\u{10FFFF})*$/u;
この不具合を調査して原因を特定するまでの過程を、詳しく書いていきます。

絵文字入りのXMLだけエラーになる
[DOMException InvalidStateError: Failed to serialize XML: text node data is not well-formed.]

このエラーはざっくりと「XML内のtext nodeがwell-formedになってないよ」という意味ですが、これだけではエラーを特定できませんでした。 well-formed XMLのなかでは絵文字を使えない、という情報も見当たりません。

原因調査の初期段階ではどこに原因があるか分からなかったため、とりあえず以下の条件でDOMParserの動作確認をしました。

実行環境
Node.js
Chromeブラウザ
データ形式
HTML
XML
含める文字種
絵文字「🔍」あり
絵文字なし
HTMLでの動作確認用コード

// Node.jsで動作確認する場合、ここから先を実行する。
const { JSDOM } = await import("jsdom");
const { window } = new JSDOM("");

// Chromeブラウザで動作確認する場合、ここから先を実行する。
const html = "<html><p>🔍Search</p></html>";
new window.DOMParser().parseFromString(html, "text/html").documentElement.innerHTML;
補足

SVGはXMLの一種です。
text/xmlでもimage/svg+xmlでも、DOMParser.parseFromString()は同じ動作をします。
2x2x2通りの計8通りで動作確認したところ、「jsdom」で「絵文字入り」の「XML」を読み込んだときのみエラーが発生しました。 さらに調べると、XMLを読みこんだあとdocumentElement.innerHTMLを取得するタイミングでエラーが起きていることも分かりました。 「jsdom」「絵文字入り」「XML」「innerHTML」というキーワードに絞り込めたので、ここから先はjsdomのコードを読んでいきます。

jsdomの内部ではw3c-xmlserializerを使っている
jsdomのElement.innerHTMLのコードを読んだところ、fragmentSerialization()をrequireWellFormed: trueで呼び出してDOM nodeをシリアライズしています。 requireWellFormedオプションを適用すると、DOM nodeがwell-formed XMLの条件を満たしていないときにエラーがthrowされるようになります。

さらにfragmentSerialization()のコードを読むと、XMLをシリアライズするためにw3c-xmlserializerを利用していることが分かります。

w3c-xmlserializerのコードを読んだところ、今回のエラーの原因にたどり着きました。

W3Cの仕様書とコードに違いがあった
w3c-xmlserializerのGitリポジトリ内をエラーメッセージ「text node data is not well-formed」でgrepして、エラーが起きている箇所を見つけました。 text nodeをシリアライズするときに、そのテキストがwell-formed XMLの条件を満たしているか確認しています。

well-formed XMLで使える文字種は、正規表現で定義されています。

const XML_CHAR = /^(\x09|\x0A|\x0D|\x20-\uD7FF|\uE000-\uFFFD|(?:\uD800-\uDBFF\uDC00-\uDFFF))*$/u;
一方でW3Cの仕様書によると、well-formed XMLで使える文字種は以下のように定義されています。

Char ::= #x9 | #xA | #xD | #x20-#xD7FF | #xE000-#xFFFD | #x10000-#x10FFFF /* any Unicode character, excluding the surrogate blocks, FFFE, and FFFF. */

w3c-xmlserializerの正規表現とW3Cの仕様書の違いを抜き出すと、

w3c-xmlserializer (?:\uD800-\uDBFF\uDC00-\uDFFF)
W3Cの仕様書 #x10000-#x10FFFF
になります。 絵文字「🔍」のコードポイントはU+1F50Dなので、W3Cの仕様上は認められている文字種です。 ここまで調べられたら、あとは実際に正規表現のマッチングを動作確認しながらコードを直すだけです。

サロゲートペアとuフラグの併用が原因
w3c-xmlserializerの正規表現では、サロゲートペアとuフラグの両方を使用しています。

const XML_CHAR = /^(\x09|\x0A|\x0D|\x20-\uD7FF|\uE000-\uFFFD|(?:\uD800-\uDBFF\uDC00-\uDFFF))*$/u;
サロゲートペア \uD800-\uDBFF\uDC00-\uDFFF
JavaScriptが採用している文字コードはUTF-16。
絵文字のようなコードポイントが0x10000以上の文字は、16ビットで表現可能な範囲0x0000 ~ 0xFFFFを超えている。
そのため2文字分(32ビット)のメモリ領域を使ってコードポイント0x10000以上の文字を表現する仕組みがサロゲートペア。
uフラグ /u
上記のようなサロゲートペアを使わずに、0x10000以上の文字を直接指定できるようになる。
たとえば絵文字「🔍」の場合は、正規表現/\u{1F50D}/uでマッチするようになる。
つまりw3c-xmlserializerの正規表現では、サロゲートペアかuフラグのどちらか一方ではなく両方を使っているため、絵文字「🔍」にマッチしなくなっていたようです。 実際にNode.js上で確認をしました。

$ node
Welcome to Node.js v16.18.1.
Type ".help" for more information.
/(?:\uD800-\uDBFF\uDC00-\uDFFF)/u.test("🔍"); // w3c-xmlserializerの正規表現
false
/(?:\uD800-\uDBFF\uDC00-\uDFFF)/.test("🔍"); // uフラグなしでサロゲートペアを使う場合
true
/(?:\u{10000}-\u{10FFFF})/u.test("🔍"); // uフラグありでサロゲートペアを使わない場合
true
なので、uフラグなしでサロゲートペアを使うか、uフラグありでサロゲートペアを使わない正規表現に直せば、絵文字「🔍」にもマッチするようになります。 今回は、uフラグありでサロゲートペアを使わないように修正してプルリクエストを送りました。 サロゲートペアは直感的でないため、扱うのが難しいですね。

Allow emoji characters as well-formed XML by hata6502 · Pull Request #27 · jsdom/w3c-xmlserializer

const XML_CHAR = /^(\x09|\x0A|\x0D|\x20-\uD7FF|\uE000-\uFFFD|
-(?:\uD800-\uDBFF\uDC00-\uDFFF))*$/u;
+\u{10000}-\u{10FFFF})*$/u;
原因調査を手厚くやってよかった
今回の不具合の調査には2~3時間かけましたが、手厚く原因調査をやってよかったと思います。 原因調査に対して無限に時間を使うことはできませんが、もしも深く原因調査せずに対策をするとしたら以下のような方法をとっていたと思います。 おすすめはできない方法です。

jsdomではなくxmldomを使ってみる。
すでにjsdomを使っているリポジトリにxmldomを入れるため、jsdomとxmldomが混在した状況になる。
なるべくインストールするNPMパッケージは最小限に留めたい。パッケージを増やすのは簡単だが、後から減らすのは大変なため。
現時点ではv0.8.6であり、安定版はリリースされてなさそう。
SVGのなかに絵文字が含まれていたら、自動的に取り除かれる仕様にしてしまう。
つまり絵文字を扱えないシステムにしてしまう。不便ですね。
調査をじっくり行ったからこそ的確な修正ができて、技術的な負債を残さずに済みました。

Scrapboxのおかげで調査も修正も捗った
株式会社Helpfeelのなかで使っているScrapboxに原因調査の過程を書き残しておいたので、今回のような記事を書くことができました。 サロゲートペアやuフラグを扱うのは難しかったのですが、2020年にshokaiさんがScrapboxにまとめたナレッジのおかげで、無事に対応を進められました。

unicodeサロゲートペアにマッチする正規表現を作る - Helpfeel社のScrapboxを一部公開

Helpfeel社では、技術的なこともデザインのことも社内制度のことも、幅広くScrapboxに書く文化が根付いています。 そのため不具合の原因調査が捗り、jsdomにプルリクエストを送ることができ、今回のような記事も書けて、知見がうまく循環していると感じます。 この感覚もぜひ他の人と共有したいので、Helpfeelに興味を持っていただけたらエンジニア採用にいらしてください!

明日のAdvent Calendarも、Helpfeelエンジニアであるyadoさんの記事です。楽しみにしています。

余談 jsdomへのコントリビュート体験もよかった
w3c-xmlserializerにプルリクエストを送ったら2週間もかからずにマージされ、その後すぐに新しいjsdom v20.0.3がリリースされました。 @domenic プルリクエストをレビューしてリリース作業までしてくださり、本当にありがとうございました!

Powered by Helpfeel