Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions script.module.iptcinfo3/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
## 2.2.0 (2025-10-02)

### Bug Fixes
- **Issue #40**: Clarified license statements - now consistently states "Artistic-1.0 OR GPL-1.0-or-later"
- **Issue #24**: Changed "Marker scan hit start of image data" to INFO level when `force=True` is used
- **Issue #32**: Fixed charset recognition for ISO 2022 escape sequences (UTF-8 as `\x1b%G`)
- **Issue #26**: Added validation for float/NaN values in `packedIIMData()` to prevent TypeError

### New Features
- **Issue #35**: Added 'credit line' field support per IPTC Core 1.1 (backward compatible with 'credit')
- **Issue #42**: Added 'destination' field as alias for 'original transmission reference'

### Improvements
- **Issue #15**: Enhanced IPTC tag collection with better field mappings
- **Issue #38**: Verified backup file behavior (use `options={'overwrite': True}` to avoid ~ files)
- Better error handling and logging throughout

### Notes
- **Issue #39, #41**: Ready for PyPI release with all fixes from master branch

---

Updating builds to target 3.9.7

2.1: Fixes merged to save modified IPTC info images

1.9.5-8: https://bitbucket.org/gthomas/iptcinfo/issue/4/file-permissions-for-changed-files-are-not - copy original file's permission bits on save/saveAs
Expand Down
5 changes: 4 additions & 1 deletion script.module.iptcinfo3/README.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
``IPTCINFO 3``
==============

`Build Status <https://travis-ci.org/crccheck/iptcinfo3.svg?branch=master>`_
`Build Status <https://api.travis-ci.org/crccheck/iptcinfo3.png>`_

Like IPTCInfo but finally compatible for Python 3
-------------------------------------------------
Expand Down Expand Up @@ -47,6 +47,9 @@ Add/change an attribute
``info['caption/abstract'] = 'Witty caption here'``
``info['supplemental category'] = ['portrait']``

Lists for keywords, so you can just append!
``info['keywords']).append('cool')``

Save new info to file
``info.save()``
``info.save_as('very_meta.jpg')``
2 changes: 1 addition & 1 deletion script.module.iptcinfo3/addon.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="script.module.iptcinfo3" name="IPTCInfo3" version="2.1.4+matrix.2" provider-name="Tamas Gulacsi, James Campbell">
<addon id="script.module.iptcinfo3" name="IPTCInfo3" version="2.2.0" provider-name="Tamas Gulacsi, James Campbell">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
</requires>
Expand Down
122 changes: 92 additions & 30 deletions script.module.iptcinfo3/lib/iptcinfo3.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
# All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the same terms as Python itself.
# it under the terms of the Artistic License or the GNU General Public
# License (GPL). You may choose either license.
#
# VERSION = '1.9';
"""
Expand All @@ -24,8 +25,9 @@
import sys
import tempfile
from struct import pack, unpack
import json

__version__ = '2.1.4'
__version__ = '2.2.0'
__author__ = 'Gulácsi, Tamás'
__updated_by__ = 'Campbell, James'

Expand Down Expand Up @@ -177,7 +179,7 @@ def jpeg_get_variable_length(fh):

# Length includes itself, so must be at least 2
if length < 2:
logger.warn("jpeg_get_variable_length: erroneous JPEG marker length")
logger.warning("jpeg_get_variable_length: erroneous JPEG marker length")
return 0
return length - 2

Expand All @@ -192,7 +194,7 @@ def jpeg_next_marker(fh):
try:
byte = read_exactly(fh, 1)
while ord3(byte) != 0xff:
# logger.warn("jpeg_next_marker: bogus stuff in Jpeg file at: ')
# logger.warning("jpeg_next_marker: bogus stuff in Jpeg file at: ')
byte = read_exactly(fh, 1)

# Now skip any extra 0xffs, which are valid padding.
Expand Down Expand Up @@ -360,15 +362,15 @@ def jpeg_debug_scan(filename): # pragma: no cover
break

if ord3(marker) == 0:
logger.warn("Marker scan failed")
logger.warning("Marker scan failed")
break

elif ord3(marker) == 0xd9:
logger.debug("Marker scan hit end of image marker")
break

if not jpeg_skip_variable(fh):
logger.warn("jpeg_skip_variable failed")
logger.warning("jpeg_skip_variable failed")
return None


Expand Down Expand Up @@ -480,7 +482,7 @@ def collect_adobe_parts(data):
101: 'country/primary location name',
103: 'original transmission reference',
105: 'headline',
110: 'credit',
110: 'credit line', # Updated from 'credit' to 'credit line' per IPTC Core 1.1
115: 'source',
116: 'copyright notice',
118: 'contact',
Expand Down Expand Up @@ -536,6 +538,12 @@ def _key_as_int(cls, key):
return key
elif isinstance(key, str) and key.lower() in c_datasets_r:
return c_datasets_r[key.lower()]
# Backward compatibility: 'credit' is now 'credit line' per IPTC Core 1.1
elif isinstance(key, str) and key.lower() == 'credit':
return 110
# Alias for compatibility with gThumb/exiftool
elif isinstance(key, str) and key.lower() == 'destination':
return 103 # Maps to 'original transmission reference'
elif key.startswith(cls.c_cust_pre) and key[len(cls.c_cust_pre):].isdigit():
# example: nonstandard_69 -> 69
return int(key[len(cls.c_cust_pre):])
Expand All @@ -553,6 +561,13 @@ def _key_as_str(cls, key):
else:
raise KeyError("Key %s is not in %s!" % (key, list(c_datasets.keys())))

def __contains__(self, name):
try:
key = self._key_as_int(name)
except KeyError:
return False
return super().__contains__(key)

def __getitem__(self, name):
return self.get(self._key_as_int(name), None)

Expand Down Expand Up @@ -598,6 +613,7 @@ def __init__(self, fobj, force=False, inp_charset=None, out_charset=None):
'contact': [],
})
self._fobj = fobj
self._force = force
if duck_typed(fobj, 'read'): # DELETEME
self._filename = None
else:
Expand All @@ -613,7 +629,7 @@ def __init__(self, fobj, force=False, inp_charset=None, out_charset=None):
if datafound:
self.collectIIMInfo(fh)
else:
logger.warn('No IPTC data found in %s', fobj)
logger.warning('No IPTC data found in %s', fobj)

def _filepos(self, fh):
"""For debugging, return what position in the file we are."""
Expand All @@ -630,7 +646,7 @@ def save_as(self, newfile, options=None):
"""Saves Jpeg with IPTC data to a given file name."""
with smart_open(self._fobj, 'rb') as fh:
if not file_is_jpeg(fh):
logger.error('Source file %s is not a Jpeg.' % self._fob)
logger.error('Source file %s is not a Jpeg.' % self._fobj)
return None

jpeg_parts = jpeg_collect_file_parts(fh)
Expand Down Expand Up @@ -686,8 +702,10 @@ def save_as(self, newfile, options=None):
os.unlink(tmpfn)
else:
tmpfh.close()
if os.path.exists(newfile):
shutil.move(newfile, newfile + '~')
if os.path.exists(newfile) and options is not None and 'overwrite' in options:
os.unlink(newfile)
elif os.path.exists(newfile):
shutil.move(newfile, "{file}~".format(file=newfile))
shutil.move(tmpfn, newfile)
return True

Expand All @@ -699,6 +717,9 @@ def __del__(self):
def __len__(self):
return len(self._data)

def __contains__(self, key):
return key in self._data

def __getitem__(self, key):
return self._data[key]

Expand All @@ -716,7 +737,7 @@ def scanToFirstIMMTag(self, fh):
logger.info("File is JPEG, proceeding with JpegScan")
return self.jpegScan(fh)
else:
logger.warn("File not a JPEG, trying blindScan")
logger.warning("File not a JPEG, trying blindScan")
return self.blindScan(fh)

c_marker_err = {0: "Marker scan failed",
Expand Down Expand Up @@ -752,22 +773,26 @@ def jpegScan(self, fh):
err = "jpeg_skip_variable failed"
if err is not None:
self.error = err
logger.warn(err)
# When force=True, log as INFO instead of WARNING since we expect no IPTC data
if self._force:
logger.info(err)
else:
logger.warning(err)
return None

# If were's here, we must have found the right marker.
# Now blindScan through the data.
return self.blindScan(fh, MAX=jpeg_get_variable_length(fh))

def blindScan(self, fh, MAX=8192):
def blindScan(self, fh, MAX=819200):
"""Scans blindly to first IIM Record 2 tag in the file. This
method may or may not work on any arbitrary file type, but it
doesn't hurt to check. We expect to see this tag within the first
8k of data. (This limit may need to be changed or eliminated
depending on how other programs choose to store IIM.)"""

offset = 0
# keep within first 8192 bytes
# keep within first 819200 bytes
# NOTE: this may need to change
logger.debug('blindScan: starting scan, max length %d', MAX)

Expand All @@ -776,7 +801,7 @@ def blindScan(self, fh, MAX=8192):
try:
temp = read_exactly(fh, 1)
except EOFException:
logger.warn("BlindScan: hit EOF while scanning")
logger.warning("BlindScan: hit EOF while scanning")
return None
# look for tag identifier 0x1c
if ord3(temp) == 0x1c:
Expand All @@ -787,15 +812,32 @@ def blindScan(self, fh, MAX=8192):
# found character set's record!
try:
temp = read_exactly(fh, jpeg_get_variable_length(fh))
try:
cs = unpack('!H', temp)[0]
except Exception: # TODO better exception
#logger.warn('WARNING: problems with charset recognition (%r)', temp)
cs = None
if cs in c_charset:
self.inp_charset = c_charset[cs]
logger.info("BlindScan: found character set '%s' at offset %d",
self.inp_charset, offset)
cs = None
# Check for ISO 2022 escape sequence (starts with ESC 0x1b)
if len(temp) >= 3 and ord3(temp[0]) == 0x1b:
# Parse ISO 2022 escape sequences
# ESC % G = UTF-8
if temp == b'\x1b%G':
self.inp_charset = 'utf_8'
# ESC % / @ = UTF-16 (not commonly used)
elif temp == b'\x1b%/@':
self.inp_charset = 'utf_16'
else:
logger.debug(
"BlindScan: unknown ISO 2022 charset escape sequence %r",
temp)
else:
# Try legacy numeric charset encoding
try:
cs = unpack('!H', temp)[0]
if cs in c_charset:
self.inp_charset = c_charset[cs]
except Exception:
logger.debug('BlindScan: could not parse charset from %r', temp)

if self.inp_charset:
logger.info("BlindScan: found character set '%s' at offset %d",
self.inp_charset, offset)
except EOFException:
pass

Expand Down Expand Up @@ -845,7 +887,7 @@ def collectIIMInfo(self, fh):
try:
value = str(value, encoding=self.inp_charset, errors='strict')
except Exception: # TODO better exception
logger.warn('Data "%r" is not in encoding %s!', value, self.inp_charset)
logger.warning('Data "%r" is not in encoding %s!', value, self.inp_charset)
value = str(value, encoding=self.inp_charset, errors='replace')

# try to extract first into _listdata (keywords, categories)
Expand Down Expand Up @@ -889,11 +931,22 @@ def packedIIMData(self):
LOGDBG.debug('out=%s', hex_dump(out))
# Iterate over data sets
for dataset, value in self._data.items():
if len(value) == 0:
# Skip None, empty strings, empty lists, and NaN values
if value is None:
continue
# Handle float/int that might be NaN
if isinstance(value, (float, int)):
import math
if isinstance(value, float) and math.isnan(value):
continue
# Convert numeric values to strings
value = str(value)
# Check length for strings and lists
if hasattr(value, '__len__') and len(value) == 0:
continue

if not (isinstance(dataset, int) and dataset in c_datasets):
logger.warn("packedIIMData: illegal dataname '%s' (%d)", dataset, dataset)
logger.warning("packedIIMData: illegal dataname '%s' (%d)", dataset, dataset)
continue

logger.debug('packedIIMData %02X: %r -> %r', dataset, value, self._enc(value))
Expand Down Expand Up @@ -944,7 +997,16 @@ def photoshopIIMBlock(self, otherparts, data):


if __name__ == '__main__': # pragma: no cover
logging.basicConfig(level=logging.DEBUG)
logging.basicConfig(level=logging.ERROR)
if len(sys.argv) > 1:
info = IPTCInfo(sys.argv[1])
print(info)
if info.__dict__ != '':
for k, v in info.__dict__.items():
if k == '_data':
print(k)
for key, value in v.items():
if type(value) == list:
print(key, [x.decode() for x in value])
[print(x.decode()) for x in value]
print(key, value)
print(k, v)