2024年2月29日 星期四

Code Point, Surrogate Pair 和 HTML Unicode 編碼

在這篇文章中,我要來紀錄一下 Code Point (字符代碼、碼位、編碼位置) 、Surrogate Pair (代理對)、HTML Unicode 編碼之間的關係,並且示範一下在 Java 中儲存的 UTF-16 字元要如何轉成可在 HTML 中正確顯示的 Unicode 編碼。

首先,先說明一下以下名詞的意義:

  1. Code Point (字符代碼、碼位、編碼位置):
    Code Point 是一個字符的 Unicode 編碼代碼。
    參考 Unicode字元平面對映,Unicode 目前分成 17 組 平面 (Plane),每平面有 65536 (2 的 16 次方),編碼範圍從 0x0000 到 0x10FFFF ,每一個碼位代表某一個字符 (目前還沒有有全部使用到),例如
    "我" 的 Code Point 是 25105 (也就是 0x6211)
    "👍" 的 Code Point 是 128077 (也就是 0x1F44D)
  2. Surrogate Pair (代理對):
    Surrogate Pair 是一種儲存表示字符的一種方式,專門用來儲存 UTF-16 無法用 16 Bit 表示的字符,
    以 "👍" 為例,
    對於 Code Point 是 0xFFFF 以下的字符 UTF-16 會用 16 Bit (2 Byte) 去儲存,
    而對於 "👍" 這種 Code Point 大於 0xFFFF 的字符則會使用 Surrogate Pair 的方式用 32 Bit (4 Byte) 來儲存。

    規則如下,括弧中是以 "👍" 做的例子:
    先將字符的 Code Point 減去 0x10000 (0x1F44D - 0x10000= 0xF44D),
    把得到的值高位部份用 0 填充得到一個 20 Bit 的結果 (0000 1111 0100 0100 1101),
    再將前 10 Bit 高位加上 110110 成為 High Surrogate (1101 1000 0011 1101 = 0xD83D = 55357),
    將後 10 Bit 高位加上 110111 成為 Low Surrogate (1101 1100 0100 1101 = 0xDC4D = 56397),
    最後 UTF-16 會用 High Surrogate + Low Surrogate 為一組的方式 (稱為 Surrogate Pair) 用 32 Bit (4 Byte) 來表示這樣一個字符 ,以  "👍"  來說就是 0xD83D 0xDC4D。

    可以注意到,因為 1101 10 和 1101 11 已作為辨識 High/Low Surrogate 的用途,
    所以 1101 1000 0000 0000 (0xD800) ~ 1101 1111 1111 1111 (0xDFFF) 之間的值不會對應到 任何一個 UTF-16 字符,
    而在 Unicode 中,0xD800 ~ 0xDCFF 之中的 Code Point 也是設計給 UTF-16 的 Surrogate 使用的區塊,
    也就沒有字符的 Code Point 在 0xD800 ~ 0xDCFF 之中。
    可以參考 Unicode字元平面對映

    以 Java 來說,字符是用 UTF-16 來儲存的,
    所以 "👍" 跟 "\ud83d\udc4d" 是等價的。

  3. HTML Unicode 編碼 (或稱 HTML Character Entity 、HTML 字符實體編碼):
    在 HTML 中可以使用 Unicode 表示特殊字符,可以直接使用 10 進位及 16 進位以 Code Point 表示,以 "👍" 為例,可以用 👍 或 &#x1f44d 來表示,
    注意這裡不需使用 Surrogate Pair ,因為 HTML Unicode 是支援大於 0xFFFF 的 Code Point 字符的。

    同樣的,使用 CSS 的時候,也不需要使用 Surrogate Pair,例如:
    <style>
        div:after {
          content: "\1f44d";
        }
      </style>
    

    不過要注意的是,如果是使用 Javascript 的話,還是需要使用 Surrogate Pair 的表達方式,例如一樣以 "👍" 為例:
    console.log("\ud83d\udc4d");
    

最後,用 Java 示範一下上述的觀念,可以配合著看來更好的理解實際上的例子:
使用 JDK 11,
Maven Dependency :

<!-- https://mvnrepository.com/artifact/commons-lang/commons-lang -->
	<dependency>
	    <groupId>commons-lang</groupId>
	    <artifactId>commons-lang</artifactId>
	    <version>2.4</version>
	</dependency>
	
    <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
	<dependency>
	    <groupId>org.apache.commons</groupId>
	    <artifactId>commons-lang3</artifactId>
	    <version>3.14.0</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-text -->
	<dependency>
	    <groupId>org.apache.commons</groupId>
	    <artifactId>commons-text</artifactId>
	    <version>1.11.0</version>
	</dependency>

Java:

package test;

import java.util.HashMap;
import java.util.Map;

public class UTF16Test {

	public static void main(String[] args) {
		//👍
		//Code Point (字符代碼、碼位、編碼位置): 0x1f44d = 128077 (10進位)
		//UTF-16 Surrogate Pair: \ud83d \udc4d = 55357 56397 (10進位)
		//High Surrogate: \ud83d
		//Low Surrogate: \udc4d
		//HTML Character Entity (HTML 字符實體編碼): &128077
		String str = "👍";
		
		//確認是 Code Point = 128077 是否就是 "👍"
		System.out.println(Character.toString(128077).equals(str)); // true
		
		//比較 "👍" 是否與 "\ud83d\udc4d" 等價
		System.out.println("\ud83d\udc4d".equals(str)); // true
		
		//"👍" 字串長度為2,因為是用2個 16 Bit 表示,一個 16 Bit 被當成1個 Char
		System.out.println(str.length()); // 2
		System.out.println(str.toCharArray().length); // 2
		
		//"👍" 只含有一個 Code Point,所以字串的 Code Point 陣列長度是 1
		System.out.println(str.codePoints().toArray().length); // 1
		
		//org.apache.commons.lang.StringEscapeUtils.escapeHtml(String) 
		//無法正確地將 "👍" 編碼成 HTML Character Entity,應該算是 Bug ?
		//輸出是 &#55357;&#56397;
		//這種編碼是無法在 HTML page 裡正常顯示的
		System.out.println(org.apache.commons.lang.StringEscapeUtils.escapeHtml(str)); // &#55357;&#56397;
		
		//org.apache.commons.text.StringEscapeUtils 不會對 👍 進行編碼處理,
		//所以輸出不變一樣是 "👍" ,
		//不過不編碼一樣是不能在 HTML page 裡正常顯示的
		System.out.println(org.apache.commons.text.StringEscapeUtils.escapeHtml4(str)); //👍
		
		//示範如何正確地將 "👍" 轉成可以在 HTML page 正常顯示的 HTML Character Entity,
		//也就是 &#128077;
		System.out.println(convertSurrogatePairToHtmlCode(str)); //&#128077;
		
		//示範如何從 Code Point 計算 Surrogate Pair (包括 High Surrogate 和 Low Surrogate)
		//以 "👍" 為例, Surrogate Pair 就是 \ud83d \udc4d = 55357 56397 (10進位)
		Map<String, Integer> surrogatePair = calculateSurrogatePairFromCodePoint(str.codePointAt(0));
		int highSurrogate = surrogatePair.get("highSurrogate");
		int lowSurrogate = surrogatePair.get("lowSurrogate");
		System.out.println(highSurrogate); // 55357
		System.out.println(lowSurrogate); // 56397
		System.out.println(Character.isHighSurrogate((char) highSurrogate));
		System.out.println(Character.isLowSurrogate((char) lowSurrogate));
		
		//示範如何從 Surrogate Pair 計算 Code Point
		//以 "👍" 為例, Code Point 就是 0x1f44d = 128077 (10進位)
		int codePoint = calculateCodePointFromSurrogatePair(highSurrogate, lowSurrogate);
		System.out.println(codePoint); // 128077
		System.out.println(Character.isSupplementaryCodePoint(codePoint)); // true		
	}
	
	//將文字轉成可以正常顯示的 HTML 編碼
	public static String convertSurrogatePairToHtmlCode(String str) {
		String htmlCode = "";
		
		StringBuilder htmlCodeStringBuilder = new StringBuilder();
		
		str.codePoints().forEach((int codePoint) -> {
			if (Character.isSupplementaryCodePoint(codePoint)) {
				htmlCodeStringBuilder.append(String.format("&#%d;", codePoint));
			} else {
				htmlCodeStringBuilder.append(org.apache.commons.text.StringEscapeUtils.escapeHtml4(Character.toString(codePoint)));
			}
		});
		
		htmlCode = htmlCodeStringBuilder.toString();
		return htmlCode;
	}
	
	//計算一個 Code Point 的 Surrogate Pair (代理對),
	//Surrogate Pair 含有兩部份,分別是 High Surrogate 和 Low Surrogate
	public static Map<String, Integer> calculateSurrogatePairFromCodePoint(int codePoint) {
		Map<String, Integer> surrogatePair = new HashMap<>();
		
		int s = codePoint - 0x10000;
		int highSurrogate = (s >> 10) + 0xd800;
		int lowSurrogate = (s & 0x3ff) + 0xdc00;
		
		surrogatePair.put("highSurrogate", highSurrogate);
		surrogatePair.put("lowSurrogate", lowSurrogate);
		
		return surrogatePair;
	}
	
	//計算一組 High Surrogate 和 Low Surrogate 代表的 Code Point 
	public static int calculateCodePointFromSurrogatePair(int highSurrogate, int lowSurrogate) {

		return ((highSurrogate - 0xd800) << 10) + (lowSurrogate - 0xdc00) + 0x10000;
	}
}

推薦一個線上有人寫的小工具,作者解釋了 Surrogate Pair 並寫了線上幫忙互轉 Code Point 和 Surrogate Pair 的功能:
Surrogate Pair Calculator etc.

參考資料:

  1. The Surrogate Pair Calculator etc.
  2. Unicode Surrogate Pairs
  3. Emoji character sequence &#55357;&#56391; breaks old XML process
  4. How to find the surrogate pair of a symbol in java

沒有留言 :

張貼留言