ちょいと仕事で、「指定のバイト数以下に文字列を切り捨てたいんだけど」みたいな話がありまして。
文字数じゃなくて、バイト数。
なんでそんなことしなくちゃいけないのかなぁと思いつつも、実際にやったらどういうコードになるかなぁ?と思って、せっかくなのでチャレンジ。
※後半に、NIOを使った汎用的なものを追記しました
仕様は、以下とします。
- 特定のエンコーディングに対して、対象の文字列と最大バイト数を指定する
- 文字列が最大バイト数の範囲なら、引数の文字列をそのまま返す
- 最大バイト数を超える場合は、最大バイト数以下に切り捨てる
- 切り捨てる場合、マルチバイト文字が壊れないようにすること
何も考えずに最大バイト数で切り捨てると、切り捨てられた場所によっては当然文字が壊れます。この場合は、指定された最大バイト数より、小さな大きさの文字列を返すことになります。
で、安直にいくとStringを1文字捨てて(substring)してgetBytesして長さを測って、最大バイト数を超えてたらまた1文字切り捨てて…みたいな感じになりそうですが、それだとあまりに芸がないしUnicodeとバイト変換が重なって効率悪いので、エンコーディングに合わせた方法で頑張ってみました。
対象のエンコーディングは、Windows-31JとUTF-8とします。
で、書いてみたのがこんなコード。
src/main/java/TruncateMultiByte.java
import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; public class TruncateMultiByte { public static String truncateAsWindows31J(String target, int maxByteLength) { if (target == null || target.isEmpty()) { return target; } Charset windowsCharset = Charset.forName("Windows-31J"); byte[] binary = target.getBytes(windowsCharset); int size = binary.length; if (size <= maxByteLength) { return target; } if (maxByteLength == 0) { return ""; } int last = binary[maxByteLength] & 0xff; int beforeLast = binary[maxByteLength - 1] & 0xff; int lastIndex; boolean beforeLastCharMultiByteFirst = (0x81 <= beforeLast && beforeLast <= 0x9f) || (0xe0 <= beforeLast && beforeLast <= 0xef) || (0xf0 <= beforeLast && beforeLast <= 0xf9) // 外字領域 || (0xfa <= beforeLast && beforeLast <= 0xfc); // 外字領域 boolean lastCharMultiByteFirst = (0x81 <= last && last <= 0x9f) || (0xe0 <= last && last <= 0xef) || (0xf0 <= last && last <= 0xf9) // 外字領域 || (0xfa <= last && last <= 0xfc); // 外字領域 boolean lastCharMultiByteSecond = (0x40 <= last && last <= 0x7e) || (0x80 <= last && last <= 0xfc); if (last <= 0x7f) { if (beforeLastCharMultiByteFirst) { // multi byte if (lastCharMultiByteSecond) { // multi byte second lastIndex = maxByteLength - 1; } else { throw new IllegalArgumentException("Windows-31Jの範囲外のバイトです[0x" + Integer.toHexString(last) + "]"); } } else { // single byte lastIndex = maxByteLength; } } else if (beforeLastCharMultiByteFirst) { if (lastCharMultiByteSecond) { // multi byte second lastIndex = maxByteLength - 1; } else { throw new IllegalArgumentException("Windows-31Jの範囲外のバイトです[0x" + Integer.toHexString(last) + "]"); } } else if (lastCharMultiByteFirst) { // multi byte first lastIndex = maxByteLength; } else { throw new IllegalArgumentException("Windows-31Jの範囲外のバイトです[0x" + Integer.toHexString(last) + "]"); } return new String(Arrays.copyOf(binary, lastIndex), windowsCharset); } public static String truncateAsUtf8(String target, int maxByteLength) { if (target == null || target.isEmpty()) { return target; } Charset utf8Charset = StandardCharsets.UTF_8; byte[] binary = target.getBytes(utf8Charset); int size = binary.length; if (size <= maxByteLength) { return target; } int last = binary[maxByteLength] & 0xff; int lastIndex; if ((last & 0x80) == 0) { // single byte lastIndex = maxByteLength; } else { // multi byte int foundIndex = -1; for (int currentIndex = maxByteLength; currentIndex >= 0; currentIndex--) { int b = binary[currentIndex] & 0xff; if ((b & 0xc0) == 0xc0) { foundIndex = currentIndex; break; } } if (foundIndex > 0) { lastIndex = foundIndex; } else { throw new IllegalArgumentException("Can't Truncate String"); } } return new String(Arrays.copyOf(binary, lastIndex), utf8Charset); } }
テスト。
src/test/java/TruncateMultiByteTest.java
import org.junit.Test; import static org.fest.assertions.api.Assertions.*; import java.nio.charset.Charset; import sun.io.CharToByteConverter; public class TruncateMultiByteTest { @Test public void windows31jTest() { assertThat(TruncateMultiByte.truncateAsWindows31J(null, 10)) .isEqualTo(null); assertThat(TruncateMultiByte.truncateAsWindows31J("", 10)) .isEqualTo(""); assertThat(TruncateMultiByte.truncateAsWindows31J("012345", 10)) .isEqualTo("012345"); assertThat(TruncateMultiByte.truncateAsWindows31J("あいうえ", 10)) .isEqualTo("あいうえ"); assertThat(TruncateMultiByte.truncateAsWindows31J("0123456789", 10)) .isEqualTo("0123456789"); assertThat(TruncateMultiByte.truncateAsWindows31J("あいうえお", 10)) .isEqualTo("あいうえお"); assertThat(TruncateMultiByte.truncateAsWindows31J("0123456789123", 10)) .isEqualTo("0123456789"); assertThat(TruncateMultiByte.truncateAsWindows31J("あいうえおかきくけこ", 10)) .isEqualTo("あいうえお"); // 0x82a6 assertThat(TruncateMultiByte.truncateAsWindows31J("aaaあいうえ", 10)) .isEqualTo("aaaあいう"); // 0x8c40 assertThat(TruncateMultiByte.truncateAsWindows31J("123456789掘", 10)) .isEqualTo("123456789"); // 0x82a0 assertThat(TruncateMultiByte.truncateAsWindows31J("123456789あ", 10)) .isEqualTo("123456789"); // 0xe2f0 assertThat(TruncateMultiByte.truncateAsWindows31J("123456789糅", 10)) .isEqualTo("123456789"); // 0x8344 assertThat(TruncateMultiByte.truncateAsWindows31J("123456789ゥ", 10)) .isEqualTo("123456789"); assertThat(TruncateMultiByte.truncateAsWindows31J("12", 1)) .isEqualTo("1"); assertThat(TruncateMultiByte.truncateAsWindows31J("12", 0)) .isEqualTo(""); } @Test public void windows31jTest2() throws Exception { CharToByteConverter ascii = CharToByteConverter.getConverter("ASCII"); CharToByteConverter jis0201 = CharToByteConverter.getConverter("JIS0201"); CharToByteConverter jis0208 = CharToByteConverter.getConverter("JIS0208"); CharToByteConverter ms932 = CharToByteConverter.getConverter("MS932"); Charset windowsCharset = Charset.forName("Windows-31J"); for (int i = 0; i <= Character.MAX_VALUE; i++) { char c = (char) i; if (ascii.canConvert(c)) { assertThat(TruncateMultiByte.truncateAsWindows31J(Character.toString(c), 1)) .isEqualTo(Character.toString(c)); } else if (jis0201.canConvert(c) || jis0208.canConvert(c)) { if (Character.toString(c).getBytes(windowsCharset).length == 1) { assertThat(TruncateMultiByte.truncateAsWindows31J(Character.toString(c), 1)) .isEqualTo(Character.toString(c)); } else { assertThat(TruncateMultiByte.truncateAsWindows31J(Character.toString(c), 1)) .isEqualTo(""); } } else if (ms932.canConvert(c)) { assertThat(TruncateMultiByte.truncateAsWindows31J(Character.toString(c), 1)) .isEqualTo(""); } else { assertThat(new String(Character.toString(c).getBytes(windowsCharset), windowsCharset)) .isEqualTo("?"); } } } @Test public void utf8Test() { assertThat(TruncateMultiByte.truncateAsUtf8(null, 10)) .isEqualTo(null); assertThat(TruncateMultiByte.truncateAsUtf8("", 10)) .isEqualTo(""); assertThat(TruncateMultiByte.truncateAsUtf8("012345", 10)) .isEqualTo("012345"); assertThat(TruncateMultiByte.truncateAsUtf8("あいう", 10)) .isEqualTo("あいう"); assertThat(TruncateMultiByte.truncateAsUtf8("あいうえ", 10)) .isEqualTo("あいう"); assertThat(TruncateMultiByte.truncateAsUtf8("0123456789あ", 10)) .isEqualTo("0123456789"); assertThat(TruncateMultiByte.truncateAsUtf8("012345678あ", 10)) .isEqualTo("012345678"); assertThat(TruncateMultiByte.truncateAsUtf8("あいうえお", 10)) .isEqualTo("あいう"); assertThat(TruncateMultiByte.truncateAsUtf8("01234567890123", 10)) .isEqualTo("0123456789"); assertThat(TruncateMultiByte.truncateAsUtf8("12", 1)) .isEqualTo("1"); assertThat(TruncateMultiByte.truncateAsUtf8("12", 0)) .isEqualTo(""); } }
とりあえず、大丈夫そう…?
追記)
NIOのCharsetEncoderを使うと、もっと汎用的に書けるようです。こちらの方がよいかも。
public static String truncate(String target, Charset charset, int byteLength) { if (target == null || target.isEmpty()) { return target; } CharsetEncoder encoder = charset.newEncoder(); if (byteLength >= encoder.maxBytesPerChar() * target.length()) { return target; } CharBuffer in = CharBuffer.wrap(new char[Math.min(target.length(), byteLength)]); target.getChars(0, Math.min(target.length(), in.length()), in.array(), 0); ByteBuffer out = ByteBuffer.allocate(byteLength); encoder.reset(); CoderResult result; if (in.hasRemaining()) { result = encoder.encode(in, out, true); } else { result = CoderResult.UNDERFLOW; } if (result.isUnderflow()) { encoder.flush(out); } return ((CharBuffer)in.flip()).toString(); }
参考書籍)

プログラマのための文字コード技術入門 (WEB+DB PRESS plus) (WEB+DB PRESS plusシリーズ)
- 作者: 矢野啓介
- 出版社/メーカー: 技術評論社
- 発売日: 2010/02/18
- メディア: 単行本(ソフトカバー)
- 購入: 34人 クリック: 578回
- この商品を含むブログ (129件) を見る