From 74c23ebe879b9c3f6818ba8f5828866830dddc1e Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sun, 26 Jul 2026 15:36:13 -0500 Subject: [PATCH] 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. --- tests/test_image_manager.py | 27 ++++++++++++++++++++++++ util/image_manager.py | 42 +++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/tests/test_image_manager.py b/tests/test_image_manager.py index 1daeaaf..daf99a4 100644 --- a/tests/test_image_manager.py +++ b/tests/test_image_manager.py @@ -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): diff --git a/util/image_manager.py b/util/image_manager.py index 2b8067e..cf1db04 100644 --- a/util/image_manager.py +++ b/util/image_manager.py @@ -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(