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
- プルリクエスト
- 不具合が修正されたリリース
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の動作確認をしました。
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;
補足
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の仕様書の違いを抜き出すと、
になります。 絵文字「🔍」のコードポイントは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
つまり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時間かけましたが、手厚く原因調査をやってよかったと思います。 原因調査に対して無限に時間を使うことはできませんが、もしも深く原因調査せずに対策をするとしたら以下のような方法をとっていたと思います。 おすすめはできない方法です。
調査をじっくり行ったからこそ的確な修正ができて、技術的な負債を残さずに済みました。
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 プルリクエストをレビューしてリリース作業までしてくださり、本当にありがとうございました!