feat(images): enhance PNG processing by stripping ICC profiles

- Implemented functionality to remove embedded ICC profiles from PNG images during preview generation.
- Added tests to ensure that the source image remains unchanged while the preview and thumbnail versions do not contain ICC data.
- Updated the image manager to utilize the new stripping function in the preview generation process.
This commit is contained in:
DazedAnon 2026-07-26 15:36:13 -05:00
parent 57379076bd
commit 74c23ebe87
2 changed files with 63 additions and 6 deletions

View file

@ -14,9 +14,11 @@ from util.image_manager import (
make_profile_assets_editable,
prepare_plain_assets_for_patch,
prepare_profile_assets_for_patch,
preview_profile_png_bytes,
registered_image_profiles,
scan_generic_image_assets,
scan_profile_assets,
thumbnail_profile_png_bytes,
)
from util.rpgmaker_images import decrypt_image_bytes, encrypt_image_bytes
@ -161,6 +163,31 @@ class GenericImageManagerTests(unittest.TestCase):
self.assertFalse((self.root / ".dazedtl").exists())
self.assertFalse((self.root / ".gitignore").exists())
def test_preview_strips_icc_profile_without_changing_source(self):
output = BytesIO()
Image.new("RGBA", (10, 8), "red").save(
output, format="PNG", icc_profile=b"outdated sRGB profile"
)
source = output.getvalue()
self.source.write_bytes(source)
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
preview = preview_profile_png_bytes(PROFILE_GENERIC, asset, None)
thumbnail = thumbnail_profile_png_bytes(PROFILE_GENERIC, asset, None)
self.assertIn(b"iCCP", source)
self.assertNotIn(b"iCCP", preview)
self.assertNotIn(b"iCCP", thumbnail)
self.assertEqual(self.source.read_bytes(), source)
with Image.open(BytesIO(preview)) as image:
self.assertEqual(image.getpixel((0, 0)), (255, 0, 0, 255))
def test_preview_leaves_png_without_icc_profile_byte_identical(self):
source = self.source.read_bytes()
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
self.assertEqual(preview_profile_png_bytes(PROFILE_GENERIC, asset, None), source)
class ImageEngineDetectionTests(unittest.TestCase):
def test_registry_exposes_rpgmaker_and_generic_capabilities(self):

View file

@ -19,6 +19,7 @@ PROFILE_RPGMAKER_MVMZ = "rpgmaker_mvmz"
PROFILE_GENERIC = "generic"
SUPPORTED_PLAIN_EXTENSIONS = (".png",)
_EXCLUDED_SCAN_DIRS = {".dazedtl", ".git", "__pycache__"}
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@dataclass(frozen=True)
@ -659,18 +660,47 @@ def prepare_profile_assets_for_patch(
raise ValueError(f"Unsupported image engine profile: {engine_id}")
def _strip_png_iccp_for_display(data: bytes) -> bytes:
"""Remove embedded ICC metadata from display bytes without touching the source PNG."""
if not data.startswith(_PNG_SIGNATURE):
return data
offset = len(_PNG_SIGNATURE)
output = bytearray(_PNG_SIGNATURE)
removed = False
while offset + 12 <= len(data):
length = int.from_bytes(data[offset : offset + 4], "big")
chunk_end = offset + 12 + length
if chunk_end > len(data):
return data
chunk_type = data[offset + 4 : offset + 8]
if chunk_type == b"iCCP":
removed = True
else:
output.extend(data[offset:chunk_end])
offset = chunk_end
if chunk_type == b"IEND":
output.extend(data[offset:])
return bytes(output) if removed else data
return data
def preview_profile_png_bytes(
engine_id: str, asset: ImageAsset, key: bytes | None
) -> bytes:
if engine_id == PROFILE_RPGMAKER_MVMZ:
from util.rpgmaker_images import preview_png_bytes
return preview_png_bytes(asset, key)
if asset.has_plain:
return asset.plain_path.read_bytes()
if asset.has_runtime_plain:
return asset.runtime_plain_path.read_bytes()
raise FileNotFoundError(asset.runtime_plain_path or asset.plain_path)
raw = preview_png_bytes(asset, key)
elif asset.has_plain:
raw = asset.plain_path.read_bytes()
elif asset.has_runtime_plain:
raw = asset.runtime_plain_path.read_bytes()
else:
raise FileNotFoundError(asset.runtime_plain_path or asset.plain_path)
return _strip_png_iccp_for_display(raw)
def thumbnail_profile_png_bytes(