CLOVER🍀

That was when it all began.

Javaで指定のバイト数以下に、文字列を切り捨てる

ちょいと仕事で、「指定のバイト数以下に文字列を切り捨てたいんだけど」みたいな話がありまして。

文字数じゃなくて、バイト数。

なんでそんなことしなくちゃいけないのかなぁと思いつつも、実際にやったらどういうコードになるかなぁ?と思って、せっかくなのでチャレンジ。
※後半に、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シリーズ)

プログラマのための文字コード技術入門 (WEB+DB PRESS plus) (WEB+DB PRESS plusシリーズ)