
ตัดข้อความไทยใน Java ให้สระ วรรณยุกต์ และอีโมจิอยู่ครบด้วย BreakIterator
พรีวิวแจ้งเตือนร้านขนมอาจกลายเป็นข้อความอ่านสะดุด เมื่อระบบตัดตรงกลางพยัญชนะกับสระหรือวรรณยุกต์ที่ตามมา อีโมจิก็เสี่ยงถูกแบ่งจนเหลือข้อมูลเพียงครึ่งตัว หากใช้ตำแหน่งใน String เป็นจำนวนตัวอักษรที่คนอ่านเห็น ผมจะทำฟังก์ชันตัดข้อความสำหรับหัวข้อการ์ดและแจ้งเตือน โดยหาขอบเขตที่เหมาะสมก่อน แล้วค่อยเติม … ครับ
WHY: พรีวิวสั้นได้โดยไม่ทำตัวอักษรขาด
- ข้อความจากลูกค้าอาจมีทั้งไทย อังกฤษ และอีโมจิ เทสต์เฉพาะชื่อสินค้าอังกฤษจึงพลาดปัญหาได้ง่าย
- กติกา “เก็บ 12 ตัว” ต้องบอกว่าหมายถึงหน่วยอะไร ทีมหน้าบ้านกับหลังบ้านจะได้เข้าใจตรงกัน
- ใช้เครื่องมือใน JDK ได้เลย เหมาะกับบริการ Java ที่ต้องย่อข้อความก่อนส่งต่อหลายช่องทาง
เอกสาร String ระบุว่า String ใช้ UTF-16 และตำแหน่งอ้างอิงหน่วย char อักขระนอกช่วงพื้นฐานบางตัวใช้สองหน่วย เรียกว่า surrogate pair ดังนั้น length() กับจำนวนตัวที่คนมองเห็นจึงอาจไม่ตรงกัน ส่วน code point คือค่ารหัส Unicode หนึ่งค่า การนับด้วย codePointCount() ช่วยแยกเรื่องนี้ออกจากหน่วย UTF-16 แต่ยังไม่ได้รวมพยัญชนะกับเครื่องหมายประกอบเป็นกลุ่มเดียว
ทำงานยังไง
เราจะนับ grapheme cluster หรือกลุ่มอักขระที่ใช้เป็นหน่วยตัวอักษรสำหรับผู้ใช้ เช่น ฐานกับเครื่องหมายประกอบ ตาม เอกสาร BreakIterator ของ JDK 21 ตัววิเคราะห์ขอบเขตอักขระมาตรฐานทำตามกฎ Extended Grapheme Cluster ของ Unicode จึงเหมาะกับการเดินผ่านข้อความในหน่วยลักษณะนี้ ทั้งนี้หน่วยตามกฎไม่ได้แปลว่าหนึ่งพยางค์หรือหนึ่งคำไทยเสมอไป
นึกถึงขนมที่เสียบมาเป็นไม้ เราหยิบออกทีละไม้พร้อมชิ้นที่ติดอยู่ด้วย การนับชิ้นส่วนย่อยแล้วตัดกลางไม้ทำให้ชุดขนมไม่ครบ ตัว BreakIterator ช่วยชี้ตำแหน่งระหว่างไม้ให้เรา แต่ไม่ได้ตัดสินว่าควรหยุดหลังขนมชื่อไหนครับ
สร้างด้วย getCharacterInstance(Locale.of("th")) แล้วส่งข้อความให้ setText() จากนั้น first() ไปขอบแรกและ next() เดินทีละขอบ จนเจอ DONE ซึ่งหมายถึงไม่มีขอบถัดไป ดัชนีที่ได้ยังเป็นตำแหน่งสำหรับ String จึงส่งให้ substring(0, end) ได้โดยตรง ความปลอดภัยมาจากการเลือก end ด้วยขอบเขต ไม่ใช่เลิกใช้ substring
กติกาตัวอย่างนี้คือเก็บเนื้อหาไม่เกิน N กลุ่ม แล้วเติม … เฉพาะเมื่อมีข้อความเหลือ เครื่องหมายนี้อยู่นอกโควตา N ถ้าข้อความว่างจะได้ค่าว่าง ถ้า N เป็นศูนย์และมีข้อความจะได้ … ส่วนค่าติดลบกับ null ถือเป็นข้อมูลเข้าที่ผิดและให้เกิด exception อย่างชัดเจน
โค้ดตัวอย่างพร้อมเทสต์
บันทึกบล็อกนี้เป็น Main.java ใช้ JDK 21 รัน javac -encoding UTF-8 Main.java แล้ว java -ea Main โดย -ea เปิดการตรวจ assert ซึ่งใช้เป็นเทสต์ในตัวอย่างนี้ ข้อมูลร้านและยอดเงินเป็นข้อมูลสมมติสำหรับทดสอบ
import java.text.BreakIterator;
import java.util.Locale;
import java.util.Objects;
public class Main {
static BreakIterator characters(String text) {
BreakIterator it = BreakIterator.getCharacterInstance(Locale.of("th"));
it.setText(text);
return it;
}
static String preview(String text, int limit) {
Objects.requireNonNull(text, "text");
if (limit < 0) throw new IllegalArgumentException("limit < 0");
BreakIterator it = characters(text);
int end = it.first();
for (int n = 0; n < limit; n++) {
int next = it.next();
if (next == BreakIterator.DONE) return text;
end = next;
}
return end == text.length() ? text : text.substring(0, end) + "…";
}
static boolean isMark(int cp) {
int type = Character.getType(cp);
return type == Character.NON_SPACING_MARK
|| type == Character.COMBINING_SPACING_MARK
|| type == Character.ENCLOSING_MARK;
}
static void check(String text, int limit) {
String result = preview(text, limit);
boolean shortened = !result.equals(text);
assert !shortened || result.endsWith("…");
String prefix = shortened
? result.substring(0, result.length() - 1) : result;
assert text.startsWith(prefix) : result;
BreakIterator it = characters(text);
assert it.isBoundary(prefix.length()) : result;
int count = 0;
for (int start = it.first(), end = it.next();
end != BreakIterator.DONE && end <= prefix.length();
start = end, end = it.next()) {
// ข้อมูลเทสต์เริ่มแต่ละกลุ่มด้วยฐาน ไม่ใช่ mark โดด ๆ
assert !isMark(text.codePointAt(start));
count++;
}
assert count <= limit;
assert !shortened || count == limit;
// ทุก surrogate ในผลลัพธ์ต้องมีคู่ครบ
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
if (Character.isHighSurrogate(c)) {
assert i + 1 < prefix.length();
assert Character.isLowSurrogate(prefix.charAt(++i));
} else {
assert !Character.isLowSurrogate(c);
}
}
}
public static void main(String[] args) {
String thai = "น้ำแข็งใสไส้ทะลัก";
String notice = "คุณมะลิ ชำระ 125 บาท วันที่ 24/10/2026 🍧";
String[] samples = {"", thai, notice, "กิข", "กุข", "ก้ข", "🍧ลด 20 บาท"};
for (String text : samples) {
for (int n = 0; n <= text.length() + 1; n++) check(text, n);
}
assert preview("", 0).equals("");
assert preview(thai, 0).equals("…");
assert preview(thai, 100).equals(thai);
assert preview("กิข", 1).equals("กิ…");
assert preview("กุข", 1).equals("กุ…");
assert preview("ก้ข", 1).equals("ก้…");
assert preview("🍧ลด 20 บาท", 1).equals("🍧…");
assert preview("กิ", 1).equals("กิ");
assert "🍧".length() == 2;
assert "🍧".codePointCount(0, "🍧".length()) == 1;
assert "กิ".codePointCount(0, "กิ".length()) == 2;
assert "กิข".substring(0, 1).equals("ก");
assert Character.isHighSurrogate("🍧".substring(0, 1).charAt(0));
try {
preview(thai, -1);
throw new AssertionError("negative limit accepted");
} catch (IllegalArgumentException expected) { }
try {
preview(null, 1);
throw new AssertionError("null accepted");
} catch (NullPointerException expected) { }
System.out.println("OK: all assertions passed");
}
}
ผมรันด้วย OpenJDK Temurin 21.0.12.1+1 พร้อม -ea แล้ว เทสต์ทั้งหมดผ่านและพิมพ์ OK: all assertions passed ครับ
เทสต์ไล่ทุกโควตา ตรวจว่าเนื้อหาเป็น prefix หรือส่วนต้นของต้นฉบับ จุดจบอยู่บนขอบเขต และ surrogate มีคู่ครบ พร้อมล็อกผลสระบน สระล่าง และวรรณยุกต์ไว้ตรง ๆ
คำว่า “ไม่เหลือเครื่องหมายประกอบโดด ๆ” ไม่ได้แปลว่าห้ามผลลัพธ์ลงท้ายด้วยเครื่องหมายประกอบ กิ ลงท้ายด้วยสระบนได้อย่างถูกต้อง เทสต์จึงตรวจว่ากลุ่มมีฐานและไม่ถูกผ่ากลาง แทนการห้าม code point สุดท้ายเป็น mark ฟังก์ชันนี้เลือกขอบเขตของข้อความที่รับมา ไม่ได้ซ่อมข้อมูลต้นทางที่มี mark โดดหรือ surrogate ขาดคู่ตั้งแต่แรก
จุดที่คนพลาดบ่อย
- ใช้
substring(0, N)ทันที: N เป็นตำแหน่ง UTF-16 อาจตัดคู่ surrogate หรือแยกเครื่องหมายจากฐาน แก้ด้วยการเดิน N ขอบแล้วใช้ดัชนีที่ได้ - นับ code point แล้วคิดว่าจบ: ตัวอย่าง
กิมีสอง code point แต่โค้ดเก็บไว้ด้วยกันเมื่อโควตาเป็นหนึ่ง ใช้หน่วยกลุ่มอักขระเมื่อต้องการพรีวิวสำหรับคนอ่าน - ลบสระหรือวรรณยุกต์ท้ายข้อความ: ทำให้ข้อความที่สมบูรณ์เสียเอง เช่น
กิแก้ด้วยการเช็กขอบเขตจากต้นฉบับ และทดสอบกลุ่มที่มีเครื่องหมายจริง - นับ
…ไม่ตรงสัญญา: โค้ดนี้เพิ่มท้ายโควตา หากผลิตภัณฑ์กำหนดจำนวนรวม ต้องออกแบบการจองที่ให้เครื่องหมายและกรณีโควตาศูนย์ใหม่ - ลืมเปิด assertions: คำสั่ง
java Mainจะไม่ตรวจ assert ตามที่ต้องการ ใช้java -ea Mainในขั้นตรวจตัวอย่าง และอย่าใช้ assert แทนการตรวจข้อมูลเข้าของระบบจริง
ใช้ตรงไหนในงานจริง
- ย่อชื่อเมนูบนการ์ดร้านอาหาร โดยเก็บข้อความเต็มไว้สำหรับหน้ารายละเอียด
- เตรียมเนื้อหาพรีวิว push notification ที่มีชื่อลูกค้าภาษาไทยและอีโมจิ
- แสดงตัวอย่าง SMS ในหน้าผู้ดูแล แต่ต้องตรวจข้อจำกัดของช่องทางส่งแยกต่างหาก จำนวนกลุ่มในบทความนี้ไม่ใช่งบความยาว SMS
- ทำฟังก์ชันกลางให้หลายบริการใช้กติกาเดียวกัน พร้อมนำเคสข้อความจริงที่ปกปิดข้อมูลส่วนบุคคลแล้วมาเพิ่มเทสต์
จำนวนกลุ่มยังไม่ใช่ความกว้างบนจอ ชื่อสองชื่อที่ได้โควตาเท่ากันอาจกินพื้นที่ต่างกัน จึงต้องทดสอบกับฟอนต์และพื้นที่แสดงผลจริงด้วย ส่วนงานที่อยากหยุดตรงคำมี getWordInstance() ให้เลือกตามเอกสาร BreakIterator แต่เป็นคนละหน่วยกับตัวอย่างนี้ และบทความนี้ไม่ได้รับรองผลแบ่งคำไทยแบบใดแบบหนึ่ง
เทียบกับทางเลือกอื่น
| approach | ดีตรงไหน | ระวังอะไร |
|---|---|---|
length() และตัดตาม char | ใช้กับตำแหน่ง UTF-16 ใน String ได้ตรง | จำนวนหน่วยอาจไม่ตรงตัวที่เห็น และจุดตัดอาจอยู่กลางกลุ่ม |
codePointCount() คู่กับ offsetByCodePoints() | ทำงานในหน่วย code point แทน char | ฐานกับเครื่องหมายประกอบยังเป็นคนละค่า |
BreakIterator.getCharacterInstance() | เลือกขอบเขตกลุ่มอักขระสำหรับพรีวิว | ต้องกำหนดโควตาและนโยบายเติมเครื่องหมายเอง |
BreakIterator.getWordInstance() | วิเคราะห์ขอบเขตคำ | ชิ้นระหว่างขอบอาจเป็นช่องว่างหรือเครื่องหมาย ไม่ใช่คำเสมอ |
รายละเอียดเมธอด code point อยู่ใน API ของ String ส่วนชิ้นระหว่างขอบคำดูได้จาก API ของ BreakIterator
Recap
- ระบุว่าโควตานับ char, code point หรือกลุ่มอักขระ
- ใช้ character BreakIterator สำหรับขอบเขตในพรีวิวนี้
- ตัด substring ด้วยดัชนีขอบเขตที่หาได้
- เติม
…เฉพาะเมื่อมีเนื้อหาถูกตัดออก - ทดสอบไทยที่มีสระบน สระล่าง วรรณยุกต์ และอีโมจิ
- ตรวจ prefix และขอบเขต โดยไม่ห้ามเครื่องหมายประกอบท้ายกลุ่มที่สมบูรณ์
- รันเทสต์ด้วย
-eaและแยกข้อจำกัดช่องทางส่งออกจากโควตาพรีวิว