Javascript で diff (内部仕様についての補足６)

  10/14 に投稿した[Javascript で diff (通信なし、ローカルで完結)](https://qiita.com/stonee/items/e2d88f07d08171b2816f)の内部仕様についての補足、その６です。
 [Javascript で diff (内部仕様についての補足)](https://qiita.com/stonee/items/8964e801f27cd9433e01)
 [Javascript で diff (内部仕様についての補足２)](https://qiita.com/stonee/items/62aebae3a4ee07c76601)
 [Javascript で diff (内部仕様についての補足３)](https://qiita.com/stonee/items/df1019d2f4877c342217)
 [Javascript で diff (内部仕様についての補足４)](https://qiita.com/stonee/items/dea3fdbdbceb0c799362)
 [Javascript で diff (内部仕様についての補足５)](https://qiita.com/stonee/items/0fe6f7870ac16745e0f0)


# 文字列を単語で区切る

単語単位の差分をとるためには、文を単語に区切る必要があります。
今回は、文を単語に区切る方法を検討することにします。

英数字のようにスペースと句読点で単語単位で区切られているものは、スペース・句読点・記号文字を目印に区切ればよさそうです。
UNICODEブロックを切り出したままの状態では、句読点と普通の文字が混じった状態ですので、句読点をひとつづつ除外していきます。


```js
//ラテン１補助のところに U+00D7「×」、U+00F7「÷」があるので記号として除外
return ((n < 0x00C0) || (n == 0x00D7) || (n == 0x00F7)) ? CB.BREAK : CB.LATIN;	//×÷

//中略

//記号のところにある「々〆〇〻」(〇は漢数字のゼロ) を漢字として除外
if (0x2E80 <= n) { return ((n < 0x3000) || "々〆〇〻".includes(c)) ? CB.KANJI : CB.BREAK; }

//中略

//「・」中黒を記号として除外
if (n == 0x30FB) { return CB.BREAK; }

//etc.

//最後にいろいろな句読点をまとめて除外！
return c.match(/[\u055A-\u055F\u0589\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u166D\u166E\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u1805\u1807-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\uA4FE\uA4FF\uA60D-\uA60F\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB]/) ?
	CB.BREAK : CB.LETTER;

```

漢字、ひらがな、カタカナ、ハングルは、一続きのUNICODEブロックに収まっていないため、UNICODEの表を見ながらひとつづつ分類しました。というより、一続きのUNICODEブロックに収まっている言語がほとんどありません……
例えば、キリル文字の場合はロシア語で使用される以外にも周辺諸国で使用されている追加文字があるので、それも含めて分類する必要があります。
カタカナの場合は、U+30A0 - U+30FF の他にも、U+3100 - U+31FF の範囲などに追加文字があります。
「々」は「CJK用の記号および分音記号」に分類されていますが、文字列をパースする上では漢字として扱いたいわけです。
また、長音記号「ー」は、

- ひらがなの後ろにあればひらがな
- カタカナの後ろにあればカタカナ

とする必要があります。
それぞれの文字を分類しただけでは単語に区切れないため、先頭から順に直前の文字の分類を覚えている状態でスキャンしていきます。
なお、HTMLの差分をとるときのため、"</" は問答無用に一単語扱いとしています。

文字単位で区切る
![区切り_文字単位1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513949/a8e71dae-9dbb-d8cd-3ccb-1f7ee2a4d329.png)

単語単位で区切る
![区切り_単語単位1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513949/77a54485-62bf-1e9f-9db2-6216a34d2989.png)



今のところ、漢字は、一文字づつ区切るようにしていますが、漢字もかたまり単位で認識させたい場合は、LineDiffEngine.SplitWord() 関数の該当部分をコメントアウトしてください。


```js
LineDiffEngine.SplitWord = function(vChar) {
//中略
		if ((c == "/") && (sWord == "") && (vResult.length > 0) && (vResult[vResult.length - 1] == "<")) {
			vResult[vResult.length - 1] = "</";		//for html
		} else {
			switch (cb) {
				case CB.KANJI:	//漢字も一続きで認識したい場合はこの行をコメントアウト
				case CB.BREAK:
				case CB.WHITE:
					vResult.push(c);
					break;
				default:
					sWord += c;
					break;
			}
		}
//中略
};
```

漢字は１文字単位で区切る
![区切り_文字単位2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513949/49588e97-d642-c224-4f9a-6d8a014a4bb9.png)

漢字は一続きの単位で区切る
![区切り_単語単位2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513949/c9f51655-7683-26af-dfff-16fdedad1454.png)

例文が変だというツッコミは禁止。


# 実際のコード

実際のコードは下記のようになりました。
LineDiffEngine.DetectCharBlock() 関数で文字の種別を判定、LineDiffEngine.SplitWord() 関数で単語単位に切り出すパースを行っています。
なお、少しでも判定回数を減らすため、

- 大雑把にUNICODEブロックの切りのいい範囲で文字コードの範囲を分けて分岐
- さらに詳細に調べる

というようにしています。
ラテン文字とCJK以外の言語で、LineDiffEngine.DetectCharBlock() 関数の終端まできた文字は LineDiffEngine.CHAR_BLOCK.LETTER に分類されます。
今のところ、絵文字は仕様を調べきれていないです……


```js
LineDiffEngine.CHAR_BLOCK = {
	  LETTER : 0			//分類しきれなかった文字はすべてここ
	, WHITE : 2				//空白
	, BREAK : 3				//記号、句読点
	, LATIN : 4				//ラテン文字
	, LEFT_BRACKET : 5		//左かっこ(行内差分の境界調整時に使用)
	, HIRAGANA : 11			//ひらがな
	, KATAKANA : 12			//カタカナ
	, INHERITED_KANA : 13	//ー゛゜ 直前の文字に依存
	, KANJI : 15			//漢字
	, HANKANA : 16			//半角カナ
	, HANGUL : 20			//ハングル
	, GREEK : 21			//ギリシャ文字
	, CYRILLIC : 22			//キリル文字
	, HEBREW : 23			//ヘブライ文字
	, ARABIC : 24			//アラビア文字
	, VARIATION : 999		//異体字セレクタ
};
LineDiffEngine.DetectCharBlock = function(c) {
	const n = c.charCodeAt(0), CB = this.CHAR_BLOCK;
	if (n < 0x0250) {
		if ((0x0061 <= n && n < 0x007B) || (0x0041 <= n && n < 0x005B) || (0x0030 <= n && n < 0x003A) || (n == 0x005F)) {
			return CB.LATIN;	//[a-zA-Z0-9_]
		}
		if (n == 0x0009 || n == 0x0020) { return CB.WHITE; }
		//if ((n == 0x00AA) || (n == 0x00B5) || (n == 0x00BA)) { return CB.LATIN; }
		return ((n < 0x00C0) || (n == 0x00D7) || (n == 0x00F7)) ? CB.BREAK : CB.LATIN;	//×÷
	} else if (0x2000 <= n && n < 0x3040) {
		if (0x2E80 <= n) { return ((n < 0x3000) || "々〆〇〻".includes(c)) ? CB.KANJI : CB.BREAK; }
		if (0x2C60 <= n && n < 0x2C80) { return CB.LATIN; }	//2C60-2C7F Latin Extended-C
		if (0x2DE0 <= n && n < 0x2E00) { return CB.CYRILLIC; }	//2DE0-2DFF Cyrillic Extended-A
		if ((0x2800 <= n && n < 0x2900) || ((0x2C00 <= n && n < 0x2DE0) && !c.match(/[\u2cf9-\u2cfc\u2cfe\u2cff\u2d70]/))) {
			return CB.LETTER;
		}
		return CB.BREAK;	//General Punctuation etc.
	} else if (0x3040 <= n && n < 0x3200) {
		if (n < 0x3100 || 0x31F0 <= n) {
			if (n == 0x30FB) { return CB.BREAK; }	//・
			if ((n == 0x30FC) || (n == 0x309B) || (n == 0x309C)) { return CB.INHERITED_KANA; }	//ー゛゜
			return ((n >= 0x30A0) ? CB.KATAKANA : CB.HIRAGANA);
		}
		if (0x3130 <= n && n < 0x3190) { return CB.HANGUL; }	//3130-318F Hangle Compatibility Jamo
		return ((0x3190 <= n && n < 0x31A0) || (0x31C0 <= n)) ? CB.KANJI : CB.BREAK;
	} else if ((0x3200 <= n && n < 0xA000) || (0xF900 <= n && n < 0xFB00)) {
		//3400-9FFF CJK Unified Ideographs Extension A, Yijing Hexagram Symbols, CJK Unified Ideographs
		if (0x3400 <= n) { return CB.KANJI; }
		return (((0x3280 <= n) || (0x3220 <= n && n < 0x3260)) ? CB.BREAK : CB.HANGUL);
	} else if (0xFF00 <= n && n < 0xFFF0) {		//Halfwidth and Fullwidth Forms
		if (0xFF61 <= n && n < 0xFFA0) { return CB.HANKANA; }
		if ((0xFF10 <= n && n < 0xFF1A) || (0xFF21 <= n && n < 0xFF3B) || (0xFF41 <= n && n < 0xFF5B)) {
			return CB.KANJI;	//０-９Ａ-Ｚａ-ｚ
		}
		return (0xFFA0 < n && n < 0xFFE0) ? CB.HANGUL : CB.BREAK;	//FFA0 hungul space
	} else if (0xD800 <= n && n < 0xDC00) {		//surrogate pair
		const trail = c.charCodeAt(1);
		if (trail < 0xDC00 || 0xDFFF < trail) { return CB.LETTER; }	//illegal
		if (0xD840 <= n && n < 0xD880) {
			return CB.KANJI;	//20000-2FFFF CJK Unified Ideographs Extension B-F, CJK Compatibility Ideographs Supplement
		} else if ((n == 0xDB40) && (0xDD00 <= trail && trail < 0xDDF0)) {
			return CB.VARIATION;	//E0100-E01EF Variaion Selectors Supplement
		} else if ((n == 0xD82C) && (trail < 0xDD30)) {
			return (trail == 0xDC00) ? CB.KATAKANA : CB.HIRAGANA
		}
		const cp = (((c - 0xD800) << 10) + (trail - 0xDC00) + 0x10000);
		return ((0x1F000 <= cp && cp < 0x1FA70) ? CB.BREAK : CB.LETTER);	//0x1F000-0x1FA6F Pictographs etc.
	} else if ((0x1100 <= n && n < 0x1200) || (0xA960 <= n && n < 0xD800 && (n < 0xA980 || 0xAC00 <= n))) {
		return CB.HANGUL;
	} else if ((0x0400 <= n && n < 0x0530) || (0xA640 <= n && n < 0xA6A0) || (0x1C80 <= n && n < 0x1C90)) {
		return ((n == 0xA673) || (n == 0xA67E)) ? CB.BREAK : CB.CYRILLIC;
	} else if ((0x0370 <= n && n < 0x0400) || (0x1F00 <= n && n < 0x2000)) {
		return ((n == 0x037E) || (n == 0x0387)) ? CB.BREAK : CB.GREEK;
	} else if ((0x0590 <= n && n < 0x0600) || (0xFB1D <= n && n < 0xFB50)) {
		return ((n == 0x05C0) || (n == 0x05C3) || (n == 0x05C6) || (n == 0x05F3) || (n == 0x05F4)) ? CB.BREAK : CB.HEBREW;
	} else if (0xFE00 <= n && n < 0xFE70) {
		return ((n < 0xFE10) ? CB.VARIATION : CB.BREAK);
	} else if ((0x0600 <= n && n < 0x0780 && (n < 0x0700 || 0x074F < n)) || (0x08A0 <= n && n < 0x0900) || (0xFB50 <= n && n < 0xFF00)) {
		return ((n == 0x060C) || (n == 0x060D) || (n == 0x061B) || (n == 0x061E) || (n == 0x061F) || (0x066A <= n && n < 0x066D) || (n == 0x06D4)) ?
			CB.BREAK : CB.ARABIC;
	} else if ((0x1E00 <= n && n < 0x1F00) || (0xA720 <= n && n < 0xAB70 && (n < 0xA800 || 0xAB30 <= n)) || (0xFB00 <= n && n < 0xFB07)
	|| (0x0300 <= n && n < 0x0370)) {
		return CB.LATIN;
	} else if (0x1D00 <= n && n < 0x1D80) {	//1D00-1D7F Phonetic Extensions
		if ((0x1D26 <= n && n <= 0x1D2A) || (0x1D5D <= n && n <= 0x1D61) || (0x1D66 <= n && n <= 0x1D6A)) {
			return CB.GREEK;
		}
		return (n == 0x1D2B) ? CB.CYRILLIC : CB.LATIN;	//1D2B Cyrillic Letter Small Capital El
	}
	//return CB.LETTER;
	return c.match(/[\u055A-\u055F\u0589\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u166D\u166E\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u1805\u1807-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\uA4FE\uA4FF\uA60D-\uA60F\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB]/) ?
		CB.BREAK : CB.LETTER;
};
LineDiffEngine.SplitWord = function(vChar) {
	let vResult = [], sWord = "", bDifferent, CB = this.CHAR_BLOCK, cb_prev = CB.LETTER;
	for (let c of vChar) {
		let cb = this.DetectCharBlock(c);
		switch (cb) {
			case CB.HIRAGANA:
			case CB.KATAKANA:
				bDifferent = ((cb_prev != cb) && (cb_prev != CB.INHERITED_KANA));
				break;
			case CB.INHERITED_KANA:
				bDifferent = ((cb_prev != CB.HIRAGANA) && (cb_prev != CB.KATAKANA) && (cb_prev != cb));
				if (!bDifferent) { cb = cb_prev; }
				break;
			case CB.VARIATION:
				bDifferent = false;
				cb = cb_prev;
				break;
			default:
				bDifferent = (cb != cb_prev);
				break;
		}
		if (bDifferent) {
			if (sWord != "") {
				vResult.push(sWord);
				sWord = "";
			}
			cb_prev = cb;
		}
		if ((c == "/") && (sWord == "") && (vResult.length > 0) && (vResult[vResult.length - 1] == "<")) {
			vResult[vResult.length - 1] = "</";		//for html
		} else {
			switch (cb) {
				case CB.KANJI:
				case CB.BREAK:
				case CB.WHITE:
					vResult.push(c);
					break;
				default:
					sWord += c;
					break;
			}
		}
	}
	if (sWord != "") { vResult.push(sWord); }
	return vResult;
};
```