diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef45441..213923e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,14 @@ jobs: - name: Build source and wheel dist # Uses hatchling as defined in pyproject.toml - run: python3 -m build + run: | + echo "==> Set prod logging" + mv batch_img/config.json batch_img/config_bk.json + cp -p batch_img/config_prod.json batch_img/config.json + echo "Build by hatchling defined in pyproject.toml" + python3 -m build + echo "==> Restore config.json" + mv batch_img/config_bk.json batch_img/config.json - name: Validate dist files run: twine check dist/* diff --git a/.pylintrc b/.pylintrc index 8ca4ce2..77b4471 100644 --- a/.pylintrc +++ b/.pylintrc @@ -11,4 +11,5 @@ ignore-paths=^(.*/)?tests?(/.*)?$ disable=missing-function-docstring, missing-class-docstring, R0801, - E1101 + E1101, + too-many-branches diff --git a/Makefile b/Makefile index 512d177..f18c90e 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,6 @@ # make check # validate the distribution files # make install-local # install into current venv and smoke-test -PYTHON_FILES = batch_img VERSION ?= $(shell python -c "from batch_img.const import __version__; print(__version__)" 2>/dev/null || echo "0.0.0") clean: @@ -18,16 +17,21 @@ clean: rm -fr docs/build out_*.yaml tmp_* lint: clean - pylint $(PYTHON_FILES) --ignore=venv,tests + pylint batch_img --ignore=venv,tests ruff check --fix --exit-non-zero-on-fix test: lint pytest --cov-report=term --cov=batch_img tests dist: clean + @echo "==> Set prod logging" + mv batch_img/config.json batch_img/config_bk.json + cp -p batch_img/config_prod.json batch_img/config.json # Build wheel and sdist packages python -m build --sdist @echo "Distribution files created in ./dist/" + @echo "==> Restore config.json" + mv batch_img/config_bk.json batch_img/config.json check: dist # Validate the distribution files diff --git a/batch_img/common.py b/batch_img/common.py index 0c42692..de0d673 100644 --- a/batch_img/common.py +++ b/batch_img/common.py @@ -37,6 +37,7 @@ UNKNOWN, VER, ) +from batch_img.exif import Exif from batch_img.log import Log pillow_heif.register_heif_opener() @@ -305,7 +306,7 @@ def sort_nested_dict(data): @staticmethod def get_image_data(file: Path) -> tuple: - """Get image file data + """Get image file data combining raw byte header parsing and Pillow Args: file: image file path @@ -315,21 +316,31 @@ def get_image_data(file: Path) -> tuple: """ size = getsize(file) m_ts = datetime.fromtimestamp(getmtime(file)).strftime(TS_2_MINUTE) + with open(file, "rb") as f: + raw_bytes = f.read(65536) + raw_meta = Exif.parse_raw_header(raw_bytes) + with Image.open(file) as img: data = img.convert("RGB") d_info = { "file_size": f"{Common.easy_file_sz(size)} ({size} bytes)", "file_ts": m_ts, "format": img.format, - "mode": img.mode, + "mode": raw_meta.get("mode") or img.mode, "size": img.size, "info": img.info, } + if "bit_depth" in raw_meta: + d_info["info"]["bit_depth"] = raw_meta["bit_depth"] + if "chroma" in raw_meta: + d_info["info"]["chroma"] = raw_meta["chroma"] + for key in ("icc_profile", "xmp"): if key in img.info: img.info.pop(key) + val = img.info.get("chroma", None) - if val: # Convert 420 to "4:2:0" + if val and isinstance(val, int): # Convert 420 to "4:2:0" img.info["chroma"] = ":".join(str(val)) exif_data = img.info.pop(EXIF, None) # safely ignor non-exist key if exif_data: diff --git a/batch_img/exif.py b/batch_img/exif.py new file mode 100644 index 0000000..093b262 --- /dev/null +++ b/batch_img/exif.py @@ -0,0 +1,225 @@ +"""Raw binary header parser for image metadata extraction. +Copyright © 2026 - Present, John Liu +""" + +import struct + +from batch_img.const import UNKNOWN + + +# pylint: disable=too-few-public-methods +class Exif: + """Extract metadata directly from raw image header bytes.""" + + @staticmethod + def _parse_png(data: bytes) -> dict: + if not data.startswith(b"\x89PNG\r\n\x1a\n") or len(data) < 26: + return {} + w, h, bit_depth, color_type = struct.unpack(">IIBB", data[16:26]) + color_modes = {0: "L", 2: "RGB", 3: "P", 4: "LA", 6: "RGBA"} + return { + "format": "PNG", + "size": (w, h), + "bit_depth": bit_depth, + "mode": color_modes.get(color_type, UNKNOWN), + } + + @staticmethod + def _parse_jpeg_chroma(comp: bytes) -> str | None: + y_samp, cb_samp, cr_samp = comp[1], comp[4], comp[7] + chroma_map = { + ((2, 2), (1, 1), (1, 1)): "4:2:0", + ((2, 1), (1, 1), (1, 1)): "4:2:2", + ((1, 1), (1, 1), (1, 1)): "4:4:4", + ((1, 2), (1, 1), (1, 1)): "4:4:0", + } + key = ( + (y_samp >> 4, y_samp & 0x0F), + (cb_samp >> 4, cb_samp & 0x0F), + (cr_samp >> 4, cr_samp & 0x0F), + ) + return chroma_map.get(key) + + @staticmethod + def _parse_jpeg(data: bytes) -> dict: + if not data.startswith(b"\xff\xd8"): + return {} + + offset = 2 + data_len = len(data) + sof_markers = { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, + } + + while offset < data_len - 1: + if data[offset] != 0xFF: + offset += 1 + continue + + marker = data[offset + 1] + if marker in {0xD8, 0xD9}: # SOI, EOI + offset += 2 + continue + + if offset + 4 > data_len: + break + + length = struct.unpack(">H", data[offset + 2 : offset + 4])[0] + if marker in sof_markers: + if offset + 10 > data_len: + break + precision, h, w, num_comp = struct.unpack( + ">BHHB", data[offset + 4 : offset + 10] + ) + meta = { + "format": "JPEG", + "size": (w, h), + "bit_depth": precision, + "mode": "L" if num_comp == 1 else "RGB", + } + + if num_comp == 3 and offset + 19 <= data_len: + chroma = Exif._parse_jpeg_chroma(data[offset + 10 : offset + 19]) + if chroma: + meta["chroma"] = chroma + return meta + + offset += 2 + length + return {} + + @staticmethod + def _parse_tiff(data: bytes) -> dict: + if not (data.startswith(b"II\x2a\x00") or data.startswith(b"MM\x00\x2a")): + return {} + + endian = "<" if data[:2] == b"II" else ">" + ifd_offset = struct.unpack(f"{endian}I", data[4:8])[0] + if ifd_offset + 2 > len(data): + return {"format": "TIFF"} + + num_entries = struct.unpack(f"{endian}H", data[ifd_offset : ifd_offset + 2])[0] + entry_offset = ifd_offset + 2 + tags = {} + + for _ in range(num_entries): + if entry_offset + 12 > len(data): + break + tag, typ, count, val = struct.unpack( + f"{endian}HHII", data[entry_offset : entry_offset + 12] + ) + entry_offset += 12 + + parsed_val = val + if typ == 3: # SHORT + if count == 1: + val_bytes = struct.pack(f"{endian}I", val) + parsed_val = struct.unpack(f"{endian}H", val_bytes[:2])[0] + elif count > 1 and val + 2 <= len(data): + parsed_val = struct.unpack(f"{endian}H", data[val : val + 2])[0] + + tags[tag] = parsed_val + + meta = {"format": "TIFF"} + if 256 in tags and 257 in tags: + meta["size"] = (tags[256], tags[257]) + if 258 in tags: + meta["bit_depth"] = tags[258] + if 262 in tags: + meta["mode"] = ( + "RGB" + if tags[262] in {2, 6} + else ("L" if tags[262] in {0, 1} else UNKNOWN) + ) + return meta + + @staticmethod + def _parse_webp(data: bytes) -> dict: + if not (data.startswith(b"RIFF") and data[8:12] == b"WEBP"): + return {} + + chunk_type = data[12:16] + if chunk_type == b"VP8 " and len(data) >= 30: + w_raw, h_raw = struct.unpack("= 25: + b0, b1, b2, b3 = data[21:25] + val = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) + return { + "format": "WEBP", + "size": ((val & 0x3FFF) + 1, ((val >> 14) & 0x3FFF) + 1), + "bit_depth": 8, + "mode": "RGBA" if (val & 0x10000000) else "RGB", + } + if chunk_type == b"VP8X" and len(data) >= 30: + has_alpha = bool(data[20] & 0x10) + w = (data[24] | (data[25] << 8) | (data[26] << 16)) + 1 + h = (data[27] | (data[28] << 8) | (data[29] << 16)) + 1 + return { + "format": "WEBP", + "size": (w, h), + "bit_depth": 8, + "mode": "RGBA" if has_alpha else "RGB", + } + return {"format": "WEBP"} + + @staticmethod + def _parse_heic(data: bytes) -> dict: + if len(data) < 12 or data[4:8] != b"ftyp": + return {} + + brand = data[8:12] + if brand not in {b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"}: + return {} + + meta = {"format": "HEIC", "bit_depth": 8, "mode": "RGB"} + ispe_idx = data.find(b"ispe") + if ispe_idx != -1 and ispe_idx + 16 <= len(data): + w, h = struct.unpack(">II", data[ispe_idx + 8 : ispe_idx + 16]) + meta["size"] = (w, h) + return meta + + @staticmethod + def parse_raw_header(data: bytes) -> dict: + """Extract metadata directly from raw image header bytes. + + Supports HEIC, JPG, PNG, TIFF, and WEBP. + + Args: + data: Raw header bytes (minimum 64 KB recommended) + + Returns: + dict: Parsed header metadata (format, size, bit_depth, mode, chroma) + """ + if not data: + return {} + parsers = ( + Exif._parse_heic, + Exif._parse_jpeg, + Exif._parse_png, + Exif._parse_tiff, + Exif._parse_webp, + ) + for parser in parsers: + meta = parser(data) + if meta: + return meta + + return {} diff --git a/batch_img/info.py b/batch_img/info.py index 9ea9406..c36fe37 100644 --- a/batch_img/info.py +++ b/batch_img/info.py @@ -75,7 +75,7 @@ def do_output(success_cnt: int, total: int, results: dict, quiet: bool) -> bool: output_file = Info.exif_output_path() try: with open(output_file, "w", encoding="utf-8") as output: - Info._write_formatted_info(output, total, results) + Info.write_formatted_info(output, total, results) log.info(f"EXIF information written to {output_file}") return success_cnt == total except OSError as exc: @@ -83,11 +83,8 @@ def do_output(success_cnt: int, total: int, results: dict, quiet: bool) -> bool: return False # Print to stdout - idx = 1 - for file, data in results.items(): - Info._output_exif_info(file, data, idx, total) - idx += 1 - log.info(f"\nRead meta info from {success_cnt}/{total} files") + Info.write_formatted_info(None, total, results) + log.info(f"Read meta info from {success_cnt}/{total} files") return success_cnt == total @staticmethod @@ -140,68 +137,82 @@ def read_exif(in_path: Path, quiet: bool = False) -> bool: return Info.do_output(success_count, total, results, quiet) @staticmethod - def _write_formatted_info(output: TextIO, total: int, results: dict) -> None: + def write_formatted_info(obj: TextIO | None, total: int, results: dict) -> None: """Write formatted EXIF information to a file. Args: - output: File object to write to + obj: File object to write to. If None, prints to logger. total: total input files count results: Dictionary of file paths to results """ idx = 1 for file, data in results.items(): - Info._output_exif_info(file, data, idx, total, output) + Info.out_meta_info(file, data, idx, total, obj) idx += 1 @staticmethod - def _output_exif_info( - file: Path, data: dict, index: int, total: int, output: TextIO | None = None + def _out(text: str, obj: TextIO | None = None) -> None: + """Helper to route string output dynamically. + + Args: + text: Text to write + obj: File object to write to. If None, prints to logger. + """ + if obj: + obj.write(text + "\n") + else: + log.info(text) + + @staticmethod + def out_meta_info( + file: Path, data: dict, index: int, total: int, obj: TextIO | None ) -> None: - """Format and output EXIF information to either a file or stdout. + """Format and output meta information to a file or stdout. Args: file: Image file path data: Dictionary containing file_info and exif data index: Current file index (1-based) total: Total number of files - output: File object to write to. If None, prints to logger. + obj: File object to write to. If None, prints to logger. """ - - def _out(text: str) -> None: - """Helper to route string output dynamically.""" - if output: - output.write(text + "\n") - else: - log.info(text) - file_info = data.get("file_info", {}) exif = data.get(EXIF, {}) # Output separator and file header - _out("─" * 60) - _out(f"{file} [{index}/{total}]") + Info._out("─" * 60, obj) + Info._out(f"{file} [{index}/{total}]", obj) # Output file info - _out(f" File Size : {file_info.get('file_size', UNKNOWN)}") - _out(f" Last Modified : {file_info.get('last_modified', UNKNOWN)}") - _out(f" Format : {file_info.get('format', UNKNOWN)}") - _out(f" Dimensions : {file_info.get('dimensions', UNKNOWN)}") - _out(f" Bit Depth : {file_info.get('bit_depth', UNKNOWN)}") - _out(f" Alpha Channel : {file_info.get('alpha_channel', UNKNOWN)}") - _out(f" Colorspace : {file_info.get('colorspace', UNKNOWN)}") - _out(f" Chroma Format : {file_info.get('chroma_format', UNKNOWN)}") + Info._out(f" File Size : {file_info.get('file_size', UNKNOWN)}", obj) + Info._out(f" Last Modified : {file_info.get('last_modified', UNKNOWN)}", obj) + Info._out(f" Format : {file_info.get('format', UNKNOWN)}", obj) + Info._out(f" Dimensions : {file_info.get('dimensions', UNKNOWN)}", obj) + Info._out(f" Bit Depth : {file_info.get('bit_depth', UNKNOWN)}", obj) + Info._out(f" Alpha Channel : {file_info.get('alpha_channel', UNKNOWN)}", obj) + Info._out(f" Colorspace : {file_info.get('colorspace', UNKNOWN)}", obj) + Info._out(f" Chroma Format : {file_info.get('chroma_format', UNKNOWN)}", obj) # Output EXIF metadata - _out("") - _out(" [ EXIF Metadata ]") + Info._out("", obj) + Info._out(" [ EXIF Metadata ]", obj) if not exif: - _out(" None (or unreadable EXIF header)") - _out("") + Info._out(" None (or unreadable EXIF header)", obj) + Info._out("", obj) return + Info.print_exif(exif, obj) + @staticmethod + def print_exif(exif: dict, obj: TextIO | None = None) -> None: + """Print EXIF data + + Args: + exif: exif data in dict + obj: File object to write to. If None, prints to logger. + """ # Map EXIF tags to friendly names - exif_map = { + label_map = { "Make": "Make", "Model": "Model", "DateTime": "Date/Time", @@ -211,27 +222,38 @@ def _out(text: str) -> None: "FocalLength": "Focal Length", "GPSLatitude": "GPS Data", } - for key, label in exif_map.items(): + found_any = False + for key, label in label_map.items(): value = exif.get(key, None) if key == "GPSLatitude": - value = "Present" if value else "None" + value = "Present" if value else "Absent" elif key == "ExposureTime" and isinstance(value, tuple): value = f"{value[0]}/{value[1]} s" + found_any = True elif key == "ExposureTime" and isinstance(value, float): + found_any = True if value < 1: value = f"1/{int(1 / value)} s" else: value = f"{value} s" elif key == "FNumber" and isinstance(value, tuple): value = f"f/{value[0] / value[1]:.2f}".rstrip("0").rstrip(".") + found_any = True elif key == "FNumber" and isinstance(value, (float, int)): value = f"f/{value:.2f}".rstrip("0").rstrip(".") + found_any = True elif key == "FocalLength" and isinstance(value, tuple): value = f"{value[0] / value[1]:.2f} mm" + found_any = True elif key == "FocalLength" and isinstance(value, (float, int)): value = f"{value:.2f} mm" + found_any = True elif key == "ISOSpeedRatings" and isinstance(value, (int, str)): value = f"ISO {value}" + found_any = True - _out(f" {label:<15}: {value}") - _out("") + if value: + Info._out(f" {label:<15}: {value}", obj) + if not found_any: + Info._out(" No standard camera tags found in EXIF", obj) + Info._out("", obj) diff --git a/pyproject.toml b/pyproject.toml index bf35dc1..df03a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "batch_img" -version = "1.4.2" +version = "1.4.3" description = "Batch process (resize, rotate, remove background, remove GPS, add border, set transparency, auto do all) image files (HEIC, JPG, PNG)" readme = "README.md" authors = [{ name = "John Liu", email = "rim2rim@gmail.com" }] @@ -81,6 +81,7 @@ lint.ignore = [ "RET504", "PLR2004", # Magic value used in comparison, consider replacing `3` with a constant variable "PLW0717", # too-many-statements-in-try-clause + "PLR0912", # too-many-branches ] lint.fixable = ["E", "F", "I", "UP"] exclude = [ diff --git a/tests/data/JPG/IMG_4412.jpeg b/tests/data/JPG/IMG_4412.jpeg new file mode 100644 index 0000000..2cecae3 Binary files /dev/null and b/tests/data/JPG/IMG_4412.jpeg differ diff --git a/tests/data/JPG/meta_no_gps.txt b/tests/data/JPG/meta_no_gps.txt new file mode 100644 index 0000000..ff6f920 --- /dev/null +++ b/tests/data/JPG/meta_no_gps.txt @@ -0,0 +1,15 @@ +──────────────────────────────────────────────────────────── +/Users/john_j_liu/Downloads/IMG_4412.jpeg [1/1] + File Size : 33 KB (34272 bytes) + Last Modified : 2026-08-31 11:25 + Format : JPEG + Dimensions : 320 x 240 (0.1 MP) + Bit Depth : 8 bits/channel + Alpha Channel : No + Colorspace : RGB + Chroma Format : 4:2:0 + + [ EXIF Metadata ] + GPS Data : Absent + No standard camera tags found in EXIF + diff --git a/tests/test_common.py b/tests/test_common.py index e740eb9..8ed062b 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -33,7 +33,7 @@ def os_platform(request): return request.param -@pytest.fixture(params=[(PKG_NAME, "1.4.2"), ("", "1.4.2")]) +@pytest.fixture(params=[(PKG_NAME, "1.4.3"), ("", "1.4.3")]) def ver_data(request): return request.param @@ -49,7 +49,7 @@ def test_get_version(ver_data): ( "1.9.9", PKG_NAME, - f"🔔 Update available: 1.4.2 → 1.9.9\nRun '{PKG_NAME} --update'", + f"🔔 Update available: 1.4.3 → 1.9.9\nRun '{PKG_NAME} --update'", ), ] ) @@ -67,7 +67,7 @@ def test_check_latest_version(mock_get_latest_pypi, data_check_latest_version): @pytest.fixture( params=[ - (PKG_NAME, 0, "1.4.1"), + (PKG_NAME, 0, "1.4.2"), ("bad_bogus", 1, UNKNOWN), ] ) @@ -642,6 +642,37 @@ def test_sort_nested_dict(data_nested_dict): }, }, ), + ( + Path(f"{_dir}/data/JPG/IMG_4412.jpeg"), + { + "exif": { + "ColorSpace": 1, + "ComponentsConfiguration": "\x01\x02\x03\x00", + "ExifTag": 102, + "ExifVersion": "0221", + "FlashpixVersion": "0100", + "Orientation": 1, + "SceneCaptureType": 0, + "YCbCrPositioning": 1, + }, + "file_size": "33 KB (34272 bytes)", + "file_ts": "2026-08-31 11:25", + "format": "JPEG", + "info": { + "bit_depth": 8, + "chroma": "4:2:0", + "dpi": ( + 72.0, + 72.0, + ), + }, + "mode": "RGB", + "size": ( + 320, + 240, + ), + }, + ), ] ) def data_get_image(request): @@ -725,7 +756,7 @@ def test_calculate_new_size(data_calculate_new_size): @pytest.fixture( params=[ - (Path(f"{_dir}/data/JPG"), REPLACE, 6), + (Path(f"{_dir}/data/JPG"), REPLACE, 7), (Path(f"{_dir}/data/PNG"), Path(f"{_dir}/.out/"), 2), ] ) diff --git a/tests/test_exif.py b/tests/test_exif.py new file mode 100644 index 0000000..bd305c7 --- /dev/null +++ b/tests/test_exif.py @@ -0,0 +1,112 @@ +"""Test exif.py +pytest -sv tests/test_exif.py +Copyright © 2026 - Present, John Liu +""" + +import struct +import pytest +from PIL import Image +from batch_img.exif import Exif + + +@pytest.fixture +def temp_images(tmp_path): + paths = {} + + # PNG (100x50, RGBA) + png_path = tmp_path / "test.png" + Image.new("RGBA", (100, 50), color="red").save(png_path, format="PNG") + paths["PNG"] = png_path + + # JPEG (80x40, RGB, 4:2:0) + jpg_path = tmp_path / "test.jpg" + Image.new("RGB", (80, 40), color="blue").save( + jpg_path, format="JPEG", subsampling="4:2:0" + ) + paths["JPEG"] = jpg_path + + # TIFF (60x30, RGB) + tiff_path = tmp_path / "test.tiff" + Image.new("RGB", (60, 30), color="green").save(tiff_path, format="TIFF") + paths["TIFF"] = tiff_path + + # WEBP (120x60, RGB) + webp_path = tmp_path / "test.webp" + Image.new("RGB", (120, 60), color="yellow").save(webp_path, format="WEBP") + paths["WEBP"] = webp_path + + # Synthetic HEIC (200x100) + heic_path = tmp_path / "test.heic" + heic_bytes = ( + b"\x00\x00\x00\x14ftypheic\x00\x00\x00\x00heicmif1" + b"\x00\x00\x00\x10ispe\x00\x00\x00\x00" + struct.pack(">II", 200, 100) + ) + heic_path.write_bytes(heic_bytes) + paths["HEIC"] = heic_path + + return paths + + +def test_parse_raw_header_public_api(temp_images): + for fmt, path in temp_images.items(): + data = path.read_bytes() + meta = Exif.parse_raw_header(data) + assert meta["format"] == fmt + + +def test_parse_raw_header_invalid_inputs(): + assert Exif.parse_raw_header(b"") == {} + assert Exif.parse_raw_header(b"UNSUPPORTED_HEADER_BYTES") == {} + + +def test_parse_png(temp_images): + data = temp_images["PNG"].read_bytes() + meta = Exif._parse_png(data) + assert meta == { + "format": "PNG", + "size": (100, 50), + "bit_depth": 8, + "mode": "RGBA", + } + assert Exif._parse_png(b"invalid_png") == {} + + +def test_parse_jpeg(temp_images): + data = temp_images["JPEG"].read_bytes() + meta = Exif._parse_jpeg(data) + assert meta["format"] == "JPEG" + assert meta["size"] == (80, 40) + assert meta["bit_depth"] == 8 + assert meta["mode"] == "RGB" + assert meta.get("chroma") == "4:2:0" + assert Exif._parse_jpeg(b"invalid_jpeg") == {} + + +def test_parse_tiff(temp_images): + data = temp_images["TIFF"].read_bytes() + meta = Exif._parse_tiff(data) + assert meta["format"] == "TIFF" + assert meta["size"] == (60, 30) + assert meta["bit_depth"] == 8 + assert Exif._parse_tiff(b"invalid_tiff") == {} + + +def test_parse_webp(temp_images): + data = temp_images["WEBP"].read_bytes() + meta = Exif._parse_webp(data) + assert meta["format"] == "WEBP" + assert meta["size"] == (120, 60) + assert meta["bit_depth"] == 8 + assert Exif._parse_webp(b"invalid_webp") == {} + + +def test_parse_heic(temp_images): + data = temp_images["HEIC"].read_bytes() + meta = Exif._parse_heic(data) + assert meta == { + "format": "HEIC", + "size": (200, 100), + "bit_depth": 8, + "mode": "RGB", + } + assert Exif._parse_heic(b"invalid_heic") == {} diff --git a/tests/test_info.py b/tests/test_info.py index 4c950c5..6fc81be 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -1,6 +1,6 @@ -"""Tests for info.py +"""Test info.py pytest -sv tests/test_info.py -Copyright © 2025 - Present, John Liu +Copyright © 2026 - Present, John Liu """ from os.path import dirname @@ -9,7 +9,7 @@ import pytest -from batch_img.info import Info +from batch_img.info import Info, INFO_TXT_FILE from batch_img.const import EXIF, UNKNOWN _dir = dirname(__file__) @@ -257,3 +257,56 @@ def test_read_exif_quiet_mode_write_error(self, mock_read, tmp_path, monkeypatch result = Info.read_exif(file, quiet=True) assert result is False + + +@pytest.fixture( + params=[ + ( + Path(f"{_dir}/data/JPG/IMG_4412.jpeg"), + { + "exif": { + "ColorSpace": 1, + "ComponentsConfiguration": "\x01\x02\x03\x00", + "ExifTag": 102, + "ExifVersion": "0221", + "FlashpixVersion": "0100", + "Orientation": 1, + "SceneCaptureType": 0, + "YCbCrPositioning": 1, + }, + "file_info": { + "file_size": "33 KB (34272 bytes)", + "last_modified": "2026-08-31 11:25", + "format": "JPEG", + "dimensions": "320 x 240 (0.1 MP)", + "bit_depth": "8 bits/channel", + "alpha_channel": "No", + "colorspace": "RGB", + "chroma_format": "4:2:0", + }, + }, + Path(f"{_dir}/data/JPG/meta_no_gps.txt"), + ) + ] +) +def data_out_meta_info(request): + return request.param + + +def test_out_meta_info(data_out_meta_info, tmp_path, monkeypatch): + img_file, data, truth_file = data_out_meta_info + + output_file = tmp_path / INFO_TXT_FILE + monkeypatch.setattr(Info, "exif_output_path", lambda: output_file) + + with open(output_file, "w", encoding="utf-8") as output: + Info.out_meta_info(img_file, data, 1, 1, output) + + assert output_file.exists() + # Read with .splitlines() to avoid Windows \r\n vs Linux \n assertion failures + content = output_file.read_text(encoding="utf-8").splitlines() + expected = truth_file.read_text(encoding="utf-8").splitlines() + for idx, line in enumerate(content): + if idx == 1: + continue + assert content[idx] == expected[idx]