Repository: thombashi/pathvalidate

Issue:
max_len with sanitize_filename behaves incorrectly with multi-byte unicode chars
Code to reproduce:
```py
from pathvalidate import sanitize_filename, validate_filename

filename = "図彌見視御未味尾微身実箕論學識我遠不及他段齉籲颧饕掱麒麟魑魅魍魉麤𪚥龘 爨馕龘龘憂鬱龘國龘əəəəəəəəə иߤ-кߎ߹𝒴-𝓃߫߯ р✁ту✁ть.mp3"

print("length:", len(filename))
print("bytes:", len(filename.encode()))
sanitized = sanitize_filename(filename, max_len=255)
print("sanitized length:", len(sanitized))
print("sanitized bytes:", len(sanitized.encode()))
validate_filename(sanitized, max_len=255)
```
gives output:
```
length: 114
bytes: 270
sanitized length: 114
sanitized bytes: 270
Traceback (most recent call last):
  ...
pathvalidate.error.ValidationError: [PV1101] found an invalid string length: filename is too long: expected<=255 bytes, actual=270 bytes, platform=universal, fs_encoding=utf-8, byte_count=270
```

Discussion:

Patch:
diff --git a/pathvalidate/_common.py b/pathvalidate/_common.py
index 950640e..066e5b1 100644
--- a/pathvalidate/_common.py
+++ b/pathvalidate/_common.py
@@ -138,3 +138,10 @@ def normalize_platform(name: Optional[PlatformType]) -> Platform:
 
 def findall_to_str(match: List[Any]) -> str:
     return ", ".join([repr(text) for text in match])
+
+
+def truncate_str(text: str, encoding: str, max_bytes: int) -> str:
+    str_bytes = text.encode(encoding)
+    str_bytes = str_bytes[:max_bytes]
+    # last char might be malformed, ignore it
+    return str_bytes.decode(encoding, "ignore")
diff --git a/pathvalidate/_filename.py b/pathvalidate/_filename.py
index bd3c421..1b2168f 100644
--- a/pathvalidate/_filename.py
+++ b/pathvalidate/_filename.py
@@ -11,7 +11,7 @@ from pathlib import Path, PurePath
 from typing import Optional, Pattern, Sequence, Tuple
 
 from ._base import AbstractSanitizer, AbstractValidator, BaseFile, BaseValidator
-from ._common import findall_to_str, to_str, validate_pathtype
+from ._common import findall_to_str, to_str, truncate_str, validate_pathtype
 from ._const import DEFAULT_MIN_LEN, INVALID_CHAR_ERR_MSG_TMPL, Platform
 from ._types import PathType, PlatformType
 from .error import ErrorAttrKey, ErrorReason, InvalidCharError, ValidationError
@@ -75,7 +75,7 @@ class FileNameSanitizer(AbstractSanitizer):
             raise
 
         sanitized_filename = self._sanitize_regexp.sub(replacement_text, str(value))
-        sanitized_filename = sanitized_filename[: self.max_len]
+        sanitized_filename = truncate_str(sanitized_filename, self._fs_encoding, self.max_len)
 
         try:
             self._validator.validate(sanitized_filename)


Test patch:
diff --git a/test/test_filename.py b/test/test_filename.py
index 1383e37..176b647 100644
--- a/test/test_filename.py
+++ b/test/test_filename.py
@@ -756,3 +756,20 @@ class Test_sanitize_filename:
         with pytest.raises(expected):
             sanitize_filename(value)
         assert not is_valid_filename(value)
+
+    @pytest.mark.parametrize(
+        ["value", "platform", "fs_encoding", "max_len", "expected"],
+        [
+            ["あ" * 85, "universal", "utf-8", 255, "あ" * 85],
+            ["あ" * 86, "universal", "utf-8", 255, "あ" * 85],
+            ["あ" * 126, "universal", "utf-16", 255, "あ" * 126],
+            ["あ" * 127, "universal", "utf-16", 255, "あ" * 126],
+        ],
+    )
+    def test_max_len_fs_encoding(self, value, platform, fs_encoding, max_len, expected):
+        kwargs = {
+            "platform": platform,
+            "max_len": max_len,
+            "fs_encoding": fs_encoding,
+        }
+        assert sanitize_filename(value, **kwargs) == expected