libpng/contrib/pngexif/pngexifinfo.py
Cosmin Truta ec2e58c16a pngexif: Import pngexifinfo as an externally-contributed project
We used this experimental project in the development of the PNG-EXIF
("eXIf") specification, back in 2017. The project evolved together
with the draft specification, which was finalized on 2017-Jun-15 and
approved by the PNG Group on 2017-Jul-13.

The EXIF specification, outside of the scope of PNG and libpng, is
quite complex. The libpng implementation cannot grow too much beyond
performing basic integrity checks on top of serialization. In order
to create and manipulate PNG-EXIF image files, the use of external
libraries and tools such as ExifTool is necessary.

Now, with the addition of contrib/pngexif to the libpng repository,
offline tasks like metadata inspection and linting can be performed
without importing external dependencies.
2024-02-22 11:32:53 +02:00

179 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python
"""
Show the PNG EXIF information.
Copyright (C) 2017-2020 Cosmin Truta.
Use, modification and distribution are subject to the MIT License.
Please see the accompanying file LICENSE_MIT.txt
"""
from __future__ import absolute_import, division, print_function
import argparse
import io
import re
import sys
import zlib
from bytepack import unpack_uint32be, unpack_uint8
from exifinfo import print_raw_exif_info
_PNG_SIGNATURE = b"\x89PNG\x0d\x0a\x1a\x0a"
_PNG_CHUNK_SIZE_MAX = 0x7fffffff
_READ_DATA_SIZE_MAX = 0x3ffff
def print_error(msg):
"""Print an error message to stderr."""
sys.stderr.write("%s: error: %s\n" % (sys.argv[0], msg))
def print_debug(msg):
"""Print a debug message to stderr."""
sys.stderr.write("%s: debug: %s\n" % (sys.argv[0], msg))
def _check_png(condition, chunk_sig=None):
"""Check a PNG-specific assertion."""
if condition:
return
if chunk_sig is None:
raise RuntimeError("bad PNG data")
raise RuntimeError("bad PNG data in '%s'" % chunk_sig)
def _check_png_crc(data, checksum, chunk_sig):
"""Check a CRC32 value inside a PNG stream."""
if unpack_uint32be(data) == (checksum & 0xffffffff):
return
raise RuntimeError("bad PNG checksum in '%s'" % chunk_sig)
def _extract_png_exif(data, **kwargs):
"""Extract the EXIF header and data from a PNG chunk."""
debug = kwargs.get("debug", False)
if unpack_uint8(data, 0) == 0:
if debug:
print_debug("found compressed EXIF, compression method 0")
if (unpack_uint8(data, 1) & 0x0f) == 0x08:
data = zlib.decompress(data[1:])
elif unpack_uint8(data, 1) == 0 \
and (unpack_uint8(data, 5) & 0x0f) == 0x08:
if debug:
print_debug("found uncompressed-length EXIF field")
data_len = unpack_uint32be(data, 1)
data = zlib.decompress(data[5:])
if data_len != len(data):
raise RuntimeError(
"incorrect uncompressed-length field in PNG EXIF")
else:
raise RuntimeError("invalid compression method in PNG EXIF")
if data.startswith(b"MM\x00\x2a") or data.startswith(b"II\x2a\x00"):
return data
raise RuntimeError("invalid TIFF/EXIF header in PNG EXIF")
def print_png_exif_info(instream, **kwargs):
"""Print the EXIF information found in the given PNG datastream."""
debug = kwargs.get("debug", False)
has_exif = False
while True:
chunk_hdr = instream.read(8)
_check_png(len(chunk_hdr) == 8)
chunk_len = unpack_uint32be(chunk_hdr, offset=0)
chunk_sig = chunk_hdr[4:8].decode("latin_1", errors="ignore")
_check_png(re.search(r"^[A-Za-z]{4}$", chunk_sig), chunk_sig=chunk_sig)
_check_png(chunk_len < _PNG_CHUNK_SIZE_MAX, chunk_sig=chunk_sig)
if debug:
print_debug("processing chunk: %s" % chunk_sig)
if chunk_len <= _READ_DATA_SIZE_MAX:
# The chunk size does not exceed an arbitrary, reasonable limit.
chunk_data = instream.read(chunk_len)
chunk_crc = instream.read(4)
_check_png(len(chunk_data) == chunk_len and len(chunk_crc) == 4,
chunk_sig=chunk_sig)
checksum = zlib.crc32(chunk_hdr[4:8])
checksum = zlib.crc32(chunk_data, checksum)
_check_png_crc(chunk_crc, checksum, chunk_sig=chunk_sig)
else:
# The chunk is too big. Skip it.
instream.seek(chunk_len + 4, io.SEEK_CUR)
continue
if chunk_sig == "IEND":
_check_png(chunk_len == 0, chunk_sig=chunk_sig)
break
if chunk_sig.lower() in ["exif", "zxif"] and chunk_len > 8:
has_exif = True
exif_data = _extract_png_exif(chunk_data, **kwargs)
print_raw_exif_info(exif_data, **kwargs)
if not has_exif:
raise RuntimeError("no EXIF data in PNG stream")
def print_exif_info(file, **kwargs):
"""Print the EXIF information found in the given file."""
with open(file, "rb") as stream:
header = stream.read(4)
if header == _PNG_SIGNATURE[0:4]:
if stream.read(4) != _PNG_SIGNATURE[4:8]:
raise RuntimeError("corrupted PNG file")
print_png_exif_info(instream=stream, **kwargs)
elif header == b"II\x2a\x00" or header == b"MM\x00\x2a":
data = header + stream.read(_READ_DATA_SIZE_MAX)
print_raw_exif_info(data, **kwargs)
else:
raise RuntimeError("not a PNG file")
def main():
"""The main function."""
parser = argparse.ArgumentParser(
prog="pngexifinfo",
usage="%(prog)s [options] [--] files...",
description="Show the PNG EXIF information.")
parser.add_argument("files",
metavar="file",
nargs="*",
help="a PNG file or a raw EXIF blob")
parser.add_argument("-x",
"--hex",
dest="hex",
action="store_true",
help="show EXIF tags in base 16")
parser.add_argument("-v",
"--verbose",
dest="verbose",
action="store_true",
help="run in verbose mode")
parser.add_argument("--debug",
dest="debug",
action="store_true",
help="run in debug mode")
args = parser.parse_args()
if not args.files:
parser.error("missing file operand")
result = 0
for file in args.files:
try:
print_exif_info(file,
hex=args.hex,
debug=args.debug,
verbose=args.verbose)
except (IOError, OSError) as err:
print_error(str(err))
result = 66 # os.EX_NOINPUT
except RuntimeError as err:
print_error("%s: %s" % (file, str(err)))
result = 69 # os.EX_UNAVAILABLE
parser.exit(result)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.stderr.write("INTERRUPTED\n")
sys.exit(130) # SIGINT