我正在寻找最近在 Java 中遇到的问题的解决方案:将文件名限制为 UTF-8 中的 255 个字节。

鉴于单个 UTF-8 字符可以由多个字节表示,因此这并不像以下那么简单:

String sampleString = "컴퓨터";
byte[] bytes = sampleString.getBytes("utf8");
String limitedString = new String(bytes, 0, 5, "utf8");

因为我们可以“剪切”字符,以便它最终像上面的情况一样:

컴�

我一直在寻找一个好的解决方案,但找不到。ChatGPT 建议使用StringBuilder,然后逐个添加字符并检查是否达到限制,如下所示(这不是 ChatGPT 的代码,这是我自己的解释):

String sampleString = "컴퓨터";

StringBuilder sb = new StringBuilder();
for (int i = 0; i < sampleString.length(); i++) {
    String temp = sb.toString() + sampleString.codePointAt(i); // build temporary string
    if (temp.getBytes("utf8").length > 5) {                    // convert it back to bytes and check size
        break;                                                 // if it does not fit, break
    }
    sb.append(sampleString.codePointAt(i));                    // add that tested character otherwise
}

然后结果就和预期的一样了:

但我认为这是一个非常耗费内存的解决方案。也许存在一个性能更好的解决方案?

9

  • 3
    您不应该使用charAt(可能会分割unicode字符),而应该使用codePointAt(不会)。


    – 


  • 谢谢@AndyTurner,说得好。确实,charAt返回char的并不适合所有 UTF-8 字符。


    – 

  • 1
    你可能是指char不适合(大多数) UNICODE 字符


    – 

  • 据我所知,Windows 使用 UTF-16 来编码文件名,因此如果你使用该操作系统,则限制为 255/2 个字符


    – 


  • 这是在 Linux 环境中


    – 


最佳答案
2

利用 UTF-8 中的事实,多字节代码点的每个字节(第一个字节除外)都以二进制数字 10xxxxxx 开头。如果您正在查看这样的字节,则您知道它是多字节代码点的一部分,并且您需要向后扫描,直到找到以 11xxxxxx 开头的字节以找到该代码点的开头。因此,您可以在所需的限制处切断字节数组,然后删除末尾的任何不完整的代码点(如果有):

    String sampleString = "컴퓨터";
    byte[] bytes = sampleString.getBytes(StandardCharsets.UTF_8);
    int limit = 5;
    String limitedString;
    if (limit >= bytes.length) {
        limitedString = sampleString;
    } else {
        while (limit > 0 && (bytes[limit] & 0xC0) == 0x80)
            limit--;
        limitedString = new String(bytes, 0, limit, StandardCharsets.UTF_8);
    }

3

  • 1
    @RemyLebeau 不,请注意,我从 limit开始,而不是从字符串末尾开始。因此只能有一个不完整的代码点需要删除。我修改了答案中的解释,使其更加明显。


    – 


  • @RemyLebeau 该limit变量不是索引,而是长度。因此,我查看的是字符串最后一个字节后的bytes[limit]字节。当字节是 UTF-8 序列 (10xxxxxx) 的一部分时,这意味着前一个字节也属于该字符。如果是 0xxxxxxx 或 11xxxxxx,则意味着字符串后的字节是新代码点的开始,因此字符串是完整的。bytes[limit]bytes[0 to limit-1]bytes[0 to limit-1]


    – 

  • @RemyLebeau,证明它按预期工作。(这是一个带有main手动断言的单元测试,因为在线编译器不支持 JUnit 或assert语句。)


    – 

UTF-8 的设计使得编码代码点的第一个字节指定其实际占用的字节数。因此,您可以简单地将代码点字节数相加,直到达到所需的最大字节数,例如:

int getUtf8SeqLen(byte b) {
    if ((b & 0x80) == 0x00) return 1; // 0xxxxxxx
    if ((b & 0xE0) == 0xC0) return 2; // 110xxxxx
    if ((b & 0xF0) == 0xE0) return 3; // 1110xxxx
    if ((b & 0xF8) == 0xF0) return 4; // 11110xxx
    throw new Exception("Invalid");
}
String sampleString = ...; // "컴퓨터"
byte[] bytes = sampleString.getBytes(StandardCharsets.UTF_8);
int maxBytes = ...; // 255
String limitedString;
if (bytes.length <= maxBytes) {
    limitedString = sampleString;
} else {
    int numBytes = 0, newLength = 0;
    do {
        int seqLen = getUtf8SeqLen(bytes[newLength]);
        numBytes += seqLen;
        if (numBytes > maxBytes) break;
        newLength = numBytes;
    }
    while (newLength < bytes.length);
    limitedString = new String(bytes, 0, newLength, StandardCharsets.UTF_8);
}

或者,由于您要将字节转换回String,因此您可以完全避开该数组并按原样byte[]迭代原始数组,并总结编码的代码点字节数,例如:String

int getUtf8SeqLen(int cp) {
    if (cp <= 0x007F)   return 1; // 0xxxxxxx
    if (cp <= 0x07FF)   return 2; // 110xxxxx 10xxxxxx
    if (cp <= 0xFFFF)   return 3; // 1110xxxx 10xxxxxx 10xxxxxx
    if (cp <= 0x10FFFF) return 4; // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
    throw new Exception("Invalid");
}
String sampleString = ...; // "컴퓨터"
int maxBytes = ...; // 255
int numBytes = 0, newLength = 0;
while (newLength < sampleString.length()) {
    int cp = sampleString.codePointAt(newLength);
    numBytes += getUtf8SeqLen(cp);
    if (numBytes > maxBytes) break;
    newLength += Character.charCount(cp);
}
String limitedString = sampleString.substring(0, newLength);

1

  • 我喜欢你的第二种方法,因为它更直接,不需要创建字节数组。


    –