shinestacker 1.9.0__py3-none-any.whl → 1.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shinestacker might be problematic. Click here for more details.

@@ -1,9 +1,7 @@
1
1
  # pylint: disable=C0114, C0116, W0718, R0911, R0912, E1101, R0915, R1702, R0914, R0917, R0913
2
2
  import os
3
3
  import re
4
- import io
5
4
  import logging
6
- import traceback
7
5
  import cv2
8
6
  import numpy as np
9
7
  from PIL import Image
@@ -36,18 +34,37 @@ NO_COPY_TIFF_TAGS_ID = [IMAGEWIDTH, IMAGELENGTH, RESOLUTIONX, RESOLUTIONY, BITSP
36
34
  NO_COPY_TIFF_TAGS = ["Compression", "StripOffsets", "RowsPerStrip", "StripByteCounts"]
37
35
 
38
36
 
37
+ XMP_TEMPLATE = """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
38
+ <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
39
+ <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
40
+ <rdf:Description rdf:about='' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:xmp='http://ns.adobe.com/xap/1.0/' xmlns:tiff='http://ns.adobe.com/tiff/1.0/' xmlns:exif='http://ns.adobe.com/exif/1.0/' xmlns:aux='http://ns.adobe.com/exif/1.0/aux/'>
41
+ {content}
42
+ </rdf:Description>
43
+ </rdf:RDF>
44
+ </x:xmpmeta>
45
+ <?xpacket end='w'?>""" # noqa
46
+
47
+ XMP_EMPTY_TEMPLATE = """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
48
+ <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
49
+ <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
50
+ <rdf:Description rdf:about=''/>
51
+ </rdf:RDF>
52
+ </x:xmpmeta>
53
+ <?xpacket end='w'?>""" # noqa
54
+
55
+
39
56
  def extract_enclosed_data_for_jpg(data, head, foot):
40
- size = len(foot.decode('ascii'))
41
- xmp_start, xmp_end = data.find(head), data.find(foot)
42
- if xmp_start != -1 and xmp_end != -1:
43
- return re.sub(
44
- b'[^\x20-\x7E]', b'',
45
- data[xmp_start:xmp_end + size]
46
- ).decode().replace('\x00', '').encode()
47
- return None
57
+ xmp_start = data.find(head)
58
+ if xmp_start == -1:
59
+ return None
60
+ xmp_end = data.find(foot, xmp_start)
61
+ if xmp_end == -1:
62
+ return None
63
+ xmp_end += len(foot)
64
+ return data[xmp_start:xmp_end]
48
65
 
49
66
 
50
- def get_exif(exif_filename):
67
+ def get_exif(exif_filename, enhanced_png_parsing=True):
51
68
  if not os.path.isfile(exif_filename):
52
69
  raise RuntimeError(f"File does not exist: {exif_filename}")
53
70
  image = Image.open(exif_filename)
@@ -55,12 +72,20 @@ def get_exif(exif_filename):
55
72
  return image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
56
73
  if extension_jpg(exif_filename):
57
74
  exif_data = image.getexif()
75
+ try:
76
+ exif_subifd = image.getexif().get_ifd(34665)
77
+ exif_data.update(exif_subifd)
78
+ except Exception:
79
+ pass # EXIF SubIFD is optional
58
80
  with open(exif_filename, 'rb') as f:
59
- data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
81
+ data = extract_enclosed_data_for_jpg(
82
+ f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
60
83
  if data is not None:
61
84
  exif_data[XMLPACKET] = data
62
85
  return exif_data
63
86
  if extension_png(exif_filename):
87
+ if enhanced_png_parsing:
88
+ return get_enhanced_exif_from_png(image)
64
89
  exif_data = get_exif_from_png(image)
65
90
  return exif_data if exif_data else image.getexif()
66
91
  return image.getexif()
@@ -68,58 +93,147 @@ def get_exif(exif_filename):
68
93
 
69
94
  def get_exif_from_png(image):
70
95
  exif_data = {}
71
- try:
72
- exif_from_image = image.getexif()
73
- if exif_from_image:
74
- exif_data.update(dict(exif_from_image))
75
- except Exception:
76
- pass
77
- try:
78
- if hasattr(image, 'text') and image.text:
79
- for key, value in image.text.items():
96
+ exif_from_image = image.getexif()
97
+ if exif_from_image:
98
+ exif_data.update(dict(exif_from_image))
99
+ for attr_name in ['text', 'info']:
100
+ if hasattr(image, attr_name) and getattr(image, attr_name):
101
+ for key, value in getattr(image, attr_name).items():
102
+ if attr_name == 'info' and key in ['dpi', 'gamma']:
103
+ continue
80
104
  exif_data[f"PNG_{key}"] = value
81
- if hasattr(image, 'info') and image.info:
82
- for key, value in image.info.items():
83
- if key not in ['dpi', 'gamma']:
84
- exif_data[f"PNG_{key}"] = value
85
- except Exception:
86
- pass
87
105
  return exif_data
88
106
 
89
107
 
90
- def exif_extra_tags_for_tif(exif):
91
- logger = logging.getLogger(__name__)
92
- res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
93
- if not (res_x is None or res_y is None):
94
- resolution = ((res_x.numerator, res_x.denominator), (res_y.numerator, res_y.denominator))
95
- else:
96
- resolution = ((720000, 10000), (720000, 10000))
97
- res_u = exif.get(RESOLUTIONUNIT)
98
- resolutionunit = res_u if res_u is not None else 'inch'
99
- sw = exif.get(SOFTWARE)
100
- software = sw if sw is not None else "N/A"
101
- phint = exif.get(PHOTOMETRICINTERPRETATION)
102
- photometric = phint if phint is not None else None
103
- extra = []
104
- for tag_id in exif:
105
- tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
106
- if isinstance(data, bytes):
108
+ def parse_xmp_to_exif(xmp_data):
109
+ exif_data = {}
110
+ if not xmp_data:
111
+ return exif_data
112
+ if isinstance(xmp_data, bytes):
113
+ xmp_data = xmp_data.decode('utf-8', errors='ignore')
114
+ xmp_to_exif_map = {
115
+ 'tiff:Make': 271, 'tiff:Model': 272, 'exif:ExposureTime': 33434,
116
+ 'exif:FNumber': 33437, 'exif:ISOSpeedRatings': 34855, 'exif:FocalLength': 37386,
117
+ 'exif:DateTimeOriginal': 36867, 'xmp:CreateDate': 306, 'xmp:CreatorTool': 305,
118
+ 'aux:Lens': 42036, 'exif:Flash': 37385, 'exif:WhiteBalance': 41987,
119
+ 'dc:description': 270, 'dc:creator': 315, 'dc:rights': 33432,
120
+ 'exif:ShutterSpeedValue': 37377, 'exif:ApertureValue': 37378,
121
+ 'exif:ExposureBiasValue': 37380, 'exif:MaxApertureValue': 37381,
122
+ 'exif:MeteringMode': 37383, 'exif:ExposureMode': 41986,
123
+ 'exif:SceneCaptureType': 41990
124
+ }
125
+ for xmp_tag, exif_tag in xmp_to_exif_map.items():
126
+ start_tag = f'<{xmp_tag}>'
127
+ end_tag = f'</{xmp_tag}>'
128
+ if start_tag in xmp_data:
129
+ start = xmp_data.find(start_tag) + len(start_tag)
130
+ end = xmp_data.find(end_tag, start)
131
+ if end != -1:
132
+ value = xmp_data[start:end].strip()
133
+ if value:
134
+ exif_data[exif_tag] = _parse_xmp_value(exif_tag, value)
135
+ return exif_data
136
+
137
+
138
+ def _parse_xmp_value(exif_tag, value):
139
+ if exif_tag in [33434, 33437, 37386]: # Rational values
140
+ if '/' in value:
141
+ num, den = value.split('/')
107
142
  try:
108
- if tag_id not in (IMAGERESOURCES, INTERCOLORPROFILE):
109
- if tag_id == XMLPACKET:
110
- data = re.sub(b'[^\x20-\x7E]', b'', data)
111
- data = data.decode()
112
- except Exception:
113
- logger.warning(msg=f"Copy: can't decode EXIF tag {tag:25} [#{tag_id}]")
114
- data = '<<< decode error >>>'
115
- if isinstance(data, IFDRational):
116
- data = (data.numerator, data.denominator)
117
- if tag not in NO_COPY_TIFF_TAGS and tag_id not in NO_COPY_TIFF_TAGS_ID:
118
- extra.append((tag_id, *get_tiff_dtype_count(data), data, False))
119
- else:
120
- logger.debug(msg=f"Skip tag {tag:25} [#{tag_id}]")
121
- return extra, {'resolution': resolution, 'resolutionunit': resolutionunit,
122
- 'software': software, 'photometric': photometric}
143
+ return IFDRational(int(num), int(den))
144
+ except (ValueError, ZeroDivisionError):
145
+ return float(value) if value else 0.0
146
+ return float(value) if value else 0.0
147
+ if exif_tag == 34855: # ISO
148
+ if '<rdf:li>' in value:
149
+ matches = re.findall(r'<rdf:li>([^<]+)</rdf:li>', value)
150
+ if matches:
151
+ value = matches[0]
152
+ try:
153
+ return int(value)
154
+ except ValueError:
155
+ return value
156
+ if exif_tag in [306, 36867]: # DateTime and DateTimeOriginal
157
+ if 'T' in value:
158
+ value = value.replace('T', ' ').replace('-', ':')
159
+ return value
160
+ return value
161
+
162
+
163
+ def parse_typed_png_text(value):
164
+ if isinstance(value, str):
165
+ if value.startswith('RATIONAL:'):
166
+ parts = value[9:].split('/')
167
+ if len(parts) == 2:
168
+ try:
169
+ return IFDRational(int(parts[0]), int(parts[1]))
170
+ except (ValueError, ZeroDivisionError):
171
+ return value
172
+ elif value.startswith('INT:'):
173
+ try:
174
+ return int(value[4:])
175
+ except ValueError:
176
+ return value[4:]
177
+ elif value.startswith('FLOAT:'):
178
+ try:
179
+ return float(value[6:])
180
+ except ValueError:
181
+ return value[6:]
182
+ elif value.startswith('STRING:'):
183
+ return value[7:]
184
+ elif value.startswith('BYTES:'):
185
+ return value[6:].encode('utf-8')
186
+ elif value.startswith('ARRAY:'):
187
+ return [x.strip() for x in value[6:].split(',')]
188
+ return value
189
+
190
+
191
+ def get_enhanced_exif_from_png(image):
192
+ basic_exif = get_exif_from_png(image)
193
+ enhanced_exif = {}
194
+ enhanced_exif.update(basic_exif)
195
+ xmp_data = None
196
+ if hasattr(image, 'text') and image.text:
197
+ xmp_data = image.text.get('XML:com.adobe.xmp') or image.text.get('xml:com.adobe.xmp')
198
+ if not xmp_data and 700 in basic_exif:
199
+ xmp_data = basic_exif[700]
200
+ if xmp_data:
201
+ enhanced_exif.update(parse_xmp_to_exif(xmp_data))
202
+ if hasattr(image, 'text') and image.text:
203
+ for key, value in image.text.items():
204
+ if key.startswith('EXIF_'):
205
+ parsed_value = parse_typed_png_text(value)
206
+ tag_id = _get_tag_id_from_png_key(key)
207
+ if tag_id:
208
+ enhanced_exif[tag_id] = parsed_value
209
+ return {k: v for k, v in enhanced_exif.items() if isinstance(k, int)}
210
+
211
+
212
+ def _get_tag_id_from_png_key(key):
213
+ tag_map = {
214
+ 'EXIF_CameraMake': 271, 'EXIF_CameraModel': 272, 'EXIF_Software': 305,
215
+ 'EXIF_DateTime': 306, 'EXIF_Artist': 315, 'EXIF_Copyright': 33432,
216
+ 'EXIF_ExposureTime': 33434, 'EXIF_FNumber': 33437, 'EXIF_ISOSpeed': 34855,
217
+ 'EXIF_ShutterSpeedValue': 37377, 'EXIF_ApertureValue': 37378,
218
+ 'EXIF_FocalLength': 37386, 'EXIF_LensModel': 42036,
219
+ 'EXIF_ExposureBiasValue': 37380, 'EXIF_MaxApertureValue': 37381,
220
+ 'EXIF_MeteringMode': 37383, 'EXIF_Flash': 37385, 'EXIF_WhiteBalance': 41987,
221
+ 'EXIF_ExposureMode': 41986, 'EXIF_SceneCaptureType': 41990,
222
+ 'EXIF_DateTimeOriginal': 36867
223
+ }
224
+ return tag_map.get(key)
225
+
226
+
227
+ def safe_decode_bytes(data, encoding='utf-8'):
228
+ if not isinstance(data, bytes):
229
+ return data
230
+ encodings = [encoding, 'latin-1', 'cp1252', 'utf-16', 'ascii']
231
+ for enc in encodings:
232
+ try:
233
+ return data.decode(enc, errors='strict')
234
+ except UnicodeDecodeError:
235
+ continue
236
+ return data.decode('utf-8', errors='replace')
123
237
 
124
238
 
125
239
  def get_tiff_dtype_count(value):
@@ -151,107 +265,159 @@ def get_tiff_dtype_count(value):
151
265
  return 2, len(str(value)) + 1 # Default for othre cases (ASCII string)
152
266
 
153
267
 
154
- def add_exif_data_to_jpg_file(exif, in_filenama, out_filename, verbose=False):
155
- logger = logging.getLogger(__name__)
268
+ def add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose=False):
156
269
  if exif is None:
157
270
  raise RuntimeError('No exif data provided.')
271
+ logger = logging.getLogger(__name__)
272
+ xmp_data = exif.get(XMLPACKET) if hasattr(exif, 'get') else None
273
+ with Image.open(in_filename) as image:
274
+ if hasattr(exif, 'tobytes') and 'TiffImagePlugin' in str(type(exif)):
275
+ jpeg_exif = Image.Exif()
276
+ for tag_id in exif:
277
+ if tag_id != XMLPACKET:
278
+ try:
279
+ jpeg_exif[tag_id] = exif[tag_id]
280
+ except Exception as e:
281
+ if verbose:
282
+ logger.warning(msg=f"Failed to add tag {tag_id}: {e}")
283
+ exif_bytes = jpeg_exif.tobytes()
284
+ elif hasattr(exif, 'tobytes'):
285
+ exif_bytes = exif.tobytes()
286
+ else:
287
+ jpeg_exif = Image.Exif()
288
+ for tag_id, value in exif.items():
289
+ if tag_id != XMLPACKET:
290
+ try:
291
+ jpeg_exif[tag_id] = value
292
+ except Exception as e:
293
+ if verbose:
294
+ logger.warning(msg=f"Failed to add tag {tag_id}: {e}")
295
+ exif_bytes = jpeg_exif.tobytes()
296
+ image.save(out_filename, "JPEG", exif=exif_bytes, quality=100)
297
+ if xmp_data and isinstance(xmp_data, bytes):
298
+ _insert_xmp_into_jpeg(out_filename, xmp_data, verbose)
299
+
300
+
301
+ def _insert_xmp_into_jpeg(jpeg_path, xmp_data, verbose=False):
302
+ logger = logging.getLogger(__name__)
303
+ with open(jpeg_path, 'rb') as f:
304
+ jpeg_data = f.read()
305
+ soi_pos = jpeg_data.find(b'\xFF\xD8')
306
+ if soi_pos == -1:
307
+ if verbose:
308
+ logger.warning("No SOI marker found, cannot insert XMP")
309
+ return
310
+ insert_pos = soi_pos + 2
311
+ current_pos = insert_pos
312
+ while current_pos < len(jpeg_data) - 4:
313
+ if jpeg_data[current_pos] != 0xFF:
314
+ break
315
+ marker = jpeg_data[current_pos + 1]
316
+ if marker == 0xDA:
317
+ break
318
+ segment_length = int.from_bytes(jpeg_data[current_pos + 2:current_pos + 4], 'big')
319
+ if marker == 0xE1:
320
+ insert_pos = current_pos + 2 + segment_length
321
+ current_pos = insert_pos
322
+ continue
323
+ current_pos += 2 + segment_length
324
+ xmp_identifier = b'http://ns.adobe.com/xap/1.0/\x00'
325
+ xmp_payload = xmp_identifier + xmp_data
326
+ segment_length = len(xmp_payload) + 2
327
+ xmp_segment = b'\xFF\xE1' + segment_length.to_bytes(2, 'big') + xmp_payload
328
+ updated_data = (
329
+ jpeg_data[:insert_pos] +
330
+ xmp_segment +
331
+ jpeg_data[insert_pos:]
332
+ )
333
+ with open(jpeg_path, 'wb') as f:
334
+ f.write(updated_data)
158
335
  if verbose:
159
- print_exif(exif)
160
- xmp_data = extract_enclosed_data_for_jpg(exif[XMLPACKET], b'<x:xmpmeta', b'</x:xmpmeta>')
161
- with Image.open(in_filenama) as image:
162
- with io.BytesIO() as buffer:
163
- image.save(buffer, format="JPEG", exif=exif.tobytes(), quality=100)
164
- jpeg_data = buffer.getvalue()
165
- if xmp_data is not None:
166
- app1_marker_pos = jpeg_data.find(b'\xFF\xE1')
167
- if app1_marker_pos == -1:
168
- app1_marker_pos = len(jpeg_data) - 2
169
- updated_data = (
170
- jpeg_data[:app1_marker_pos] +
171
- b'\xFF\xE1' + len(xmp_data).to_bytes(2, 'big') +
172
- xmp_data + jpeg_data[app1_marker_pos:]
173
- )
174
- else:
175
- logger.warning("Copy: can't find XMLPacket in JPG EXIF data")
176
- updated_data = jpeg_data
177
- with open(out_filename, 'wb') as f:
178
- f.write(updated_data)
179
- return exif
336
+ logger.info("Successfully inserted XMP data into JPEG")
180
337
 
181
338
 
182
339
  def create_xmp_from_exif(exif_data):
183
340
  xmp_elements = []
184
341
  if exif_data:
342
+ xmp_tag_map = {
343
+ 270: {'format': 'dc:description', 'type': 'rdf_alt', 'processor': safe_decode_bytes},
344
+ 315: {'format': 'dc:creator', 'type': 'rdf_seq', 'processor': safe_decode_bytes},
345
+ 33432: {'format': 'dc:rights', 'type': 'rdf_alt', 'processor': safe_decode_bytes},
346
+ 271: {'format': 'tiff:Make', 'type': 'simple', 'processor': safe_decode_bytes},
347
+ 272: {'format': 'tiff:Model', 'type': 'simple', 'processor': safe_decode_bytes},
348
+ 306: {'format': 'xmp:CreateDate', 'type': 'datetime', 'processor': safe_decode_bytes},
349
+ 36867: {
350
+ 'format': 'exif:DateTimeOriginal',
351
+ 'type': 'datetime',
352
+ 'processor': safe_decode_bytes
353
+ },
354
+ 305: {'format': 'xmp:CreatorTool', 'type': 'simple', 'processor': safe_decode_bytes},
355
+ 33434: {'format': 'exif:ExposureTime', 'type': 'rational', 'processor': None},
356
+ 33437: {'format': 'exif:FNumber', 'type': 'rational', 'processor': None},
357
+ 34855: {'format': 'exif:ISOSpeedRatings', 'type': 'rdf_seq', 'processor': None},
358
+ 37386: {'format': 'exif:FocalLength', 'type': 'rational', 'processor': None},
359
+ 42036: {'format': 'aux:Lens', 'type': 'simple', 'processor': safe_decode_bytes},
360
+ 37377: {'format': 'exif:ShutterSpeedValue', 'type': 'rational', 'processor': None},
361
+ 37378: {'format': 'exif:ApertureValue', 'type': 'rational', 'processor': None},
362
+ 37380: {'format': 'exif:ExposureBiasValue', 'type': 'rational', 'processor': None},
363
+ 37381: {'format': 'exif:MaxApertureValue', 'type': 'rational', 'processor': None},
364
+ 37383: {'format': 'exif:MeteringMode', 'type': 'simple', 'processor': None},
365
+ 37385: {'format': 'exif:Flash', 'type': 'simple', 'processor': None},
366
+ 41987: {
367
+ 'format': 'exif:WhiteBalance',
368
+ 'type': 'mapped',
369
+ 'processor': None,
370
+ 'map': {0: 'Auto', 1: 'Manual'}
371
+ },
372
+ 41986: {
373
+ 'format': 'exif:ExposureMode',
374
+ 'type': 'mapped',
375
+ 'processor': None,
376
+ 'map': {0: 'Auto', 1: 'Manual', 2: 'Auto bracket'}
377
+ },
378
+ 41990: {
379
+ 'format': 'exif:SceneCaptureType',
380
+ 'type': 'mapped',
381
+ 'processor': None,
382
+ 'map': {0: 'Standard', 1: 'Landscape', 2: 'Portrait', 3: 'Night scene'}
383
+ }
384
+ }
185
385
  for tag_id, value in exif_data.items():
186
- if isinstance(tag_id, int):
187
- if tag_id == 270 and value: # ImageDescription
188
- desc = value
189
- if isinstance(desc, bytes):
190
- desc = desc.decode('utf-8', errors='ignore')
386
+ if isinstance(tag_id, int) and value and tag_id in xmp_tag_map:
387
+ config = xmp_tag_map[tag_id]
388
+ processed_value = config['processor'](value) if config['processor'] else value
389
+ if config['type'] == 'simple':
390
+ xmp_elements.append(
391
+ f'<{config["format"]}>{processed_value}</{config["format"]}>')
392
+ elif config['type'] == 'rdf_alt':
393
+ xmp_elements.append(
394
+ f'<{config["format"]}><rdf:Alt>'
395
+ f'<rdf:li xml:lang="x-default">{processed_value}</rdf:li>'
396
+ f'</rdf:Alt></{config["format"]}>')
397
+ elif config['type'] == 'rdf_seq':
191
398
  xmp_elements.append(
192
- f'<dc:description><rdf:Alt><rdf:li xml:lang="x-default">{desc}</rdf:li>'
193
- '</rdf:Alt></dc:description>')
194
- elif tag_id == 315 and value: # Artist
195
- artist = value
196
- if isinstance(artist, bytes):
197
- artist = artist.decode('utf-8', errors='ignore')
399
+ f'<{config["format"]}><rdf:Seq>'
400
+ f'<rdf:li>{processed_value}</rdf:li>'
401
+ f'</rdf:Seq></{config["format"]}>')
402
+ elif config['type'] == 'datetime':
403
+ if ':' in processed_value:
404
+ processed_value = processed_value.replace(':', '-', 2).replace(' ', 'T')
198
405
  xmp_elements.append(
199
- f'<dc:creator><rdf:Seq><rdf:li>{artist}</rdf:li>'
200
- '</rdf:Seq></dc:creator>')
201
- elif tag_id == 33432 and value: # Copyright
202
- copyright_tag = value
203
- if isinstance(copyright_tag, bytes):
204
- copyright_tag = copyright_tag.decode('utf-8', errors='ignore')
406
+ f'<{config["format"]}>{processed_value}</{config["format"]}>')
407
+ elif config['type'] == 'rational':
408
+ float_value = float(value) \
409
+ if hasattr(value, 'numerator') \
410
+ else (float(value) if value else 0)
205
411
  xmp_elements.append(
206
- f'<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">{copyright_tag}</rdf:li>'
207
- '</rdf:Alt></dc:rights>')
208
- elif tag_id == 271 and value: # Make
209
- make = value
210
- if isinstance(make, bytes):
211
- make = make.decode('utf-8', errors='ignore')
212
- xmp_elements.append(f'<tiff:Make>{make}</tiff:Make>')
213
- elif tag_id == 272 and value: # Model
214
- model = value
215
- if isinstance(model, bytes):
216
- model = model.decode('utf-8', errors='ignore')
217
- xmp_elements.append(f'<tiff:Model>{model}</tiff:Model>')
218
- elif tag_id == 306 and value: # DateTime
219
- datetime_val = value
220
- if isinstance(datetime_val, bytes):
221
- datetime_val = datetime_val.decode('utf-8', errors='ignore')
222
- if ':' in datetime_val:
223
- datetime_val = datetime_val.replace(':', '-', 2).replace(' ', 'T')
224
- xmp_elements.append(f'<xmp:CreateDate>{datetime_val}</xmp:CreateDate>')
225
- elif tag_id == 305 and value: # Software
226
- software = value
227
- if isinstance(software, bytes):
228
- software = software.decode('utf-8', errors='ignore')
229
- xmp_elements.append(f'<xmp:CreatorTool>{software}</xmp:CreatorTool>')
412
+ f'<{config["format"]}>{float_value}</{config["format"]}>')
413
+ elif config['type'] == 'mapped':
414
+ mapped_value = config['map'].get(value, str(value))
415
+ xmp_elements.append(
416
+ f'<{config["format"]}>{mapped_value}</{config["format"]}>')
230
417
  if xmp_elements:
231
418
  xmp_content = '\n '.join(xmp_elements)
232
- xmp_template = f"""<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
233
- <x:xmpmeta xmlns:x='adobe:ns:meta/'
234
- x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
235
- <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
236
- <rdf:Description rdf:about=''
237
- xmlns:dc='http://purl.org/dc/elements/1.1/'
238
- xmlns:xmp='http://ns.adobe.com/xap/1.0/'
239
- xmlns:tiff='http://ns.adobe.com/tiff/1.0/'
240
- xmlns:exif='http://ns.adobe.com/exif/1.0/'>
241
- {xmp_content}
242
- </rdf:Description>
243
- </rdf:RDF>
244
- </x:xmpmeta>
245
- <?xpacket end='w'?>"""
246
- return xmp_template
247
- return """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
248
- <x:xmpmeta xmlns:x='adobe:ns:meta/'
249
- x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
250
- <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
251
- <rdf:Description rdf:about=''/>
252
- </rdf:RDF>
253
- </x:xmpmeta>
254
- <?xpacket end='w'?>"""
419
+ return XMP_TEMPLATE.format(content=xmp_content)
420
+ return XMP_EMPTY_TEMPLATE
255
421
 
256
422
 
257
423
  def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, color_order='auto'):
@@ -261,67 +427,92 @@ def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, col
261
427
  logger.warning(msg="EXIF data not supported for 16-bit PNG format")
262
428
  write_img(out_filename, image)
263
429
  return
264
- pil_image = _convert_to_pil_image(image, color_order, verbose, logger)
265
- pnginfo, icc_profile = _prepare_png_metadata(exif, verbose, logger)
266
- _save_png_with_metadata(pil_image, out_filename, pnginfo, icc_profile, verbose, logger)
430
+ pil_image = _convert_to_pil_image(image, color_order)
431
+ pnginfo, icc_profile = _prepare_png_metadata(exif)
432
+ save_args = {'format': 'PNG', 'pnginfo': pnginfo}
433
+ if icc_profile:
434
+ save_args['icc_profile'] = icc_profile
435
+ pil_image.save(out_filename, **save_args)
267
436
 
268
437
 
269
- def _convert_to_pil_image(image, color_order, verbose, logger):
270
- if isinstance(image, np.ndarray):
271
- if len(image.shape) == 3 and image.shape[2] == 3:
272
- if color_order in ['auto', 'bgr']:
273
- image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
274
- if verbose:
275
- logger.info(msg="Converted BGR to RGB for PIL")
276
- return Image.fromarray(image_rgb)
277
- return Image.fromarray(image)
278
- return image
438
+ def _convert_to_pil_image(image, color_order):
439
+ if isinstance(image, np.ndarray) and len(image.shape) == 3 and image.shape[2] == 3:
440
+ if color_order in ['auto', 'bgr']:
441
+ image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
442
+ return Image.fromarray(image_rgb)
443
+ return Image.fromarray(image) if isinstance(image, np.ndarray) else image
279
444
 
280
445
 
281
- def _prepare_png_metadata(exif, verbose, logger):
446
+ def _prepare_png_metadata(exif):
282
447
  pnginfo = PngInfo()
283
448
  icc_profile = None
284
- xmp_data = _extract_xmp_data(exif, verbose, logger)
449
+ xmp_data = _extract_xmp_data(exif)
285
450
  if xmp_data:
286
451
  pnginfo.add_text("XML:com.adobe.xmp", xmp_data)
287
- if verbose:
288
- logger.info(msg="Added XMP data to PNG info")
289
- _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger)
290
- icc_profile = _extract_icc_profile(exif, verbose, logger)
452
+ _add_exif_tags_to_pnginfo(exif, pnginfo)
453
+ icc_profile = _extract_icc_profile(exif)
291
454
  return pnginfo, icc_profile
292
455
 
293
456
 
294
- def _extract_xmp_data(exif, verbose, logger):
457
+ def _extract_xmp_data(exif):
295
458
  for key, value in exif.items():
296
459
  if isinstance(key, str) and ('xmp' in key.lower() or 'xml' in key.lower()):
297
460
  if isinstance(value, bytes):
298
- try:
299
- xmp_data = value.decode('utf-8', errors='ignore')
300
- if verbose:
301
- logger.info(msg=f"Found existing XMP data in source: {key}")
302
- return xmp_data
303
- except Exception:
304
- continue
305
- elif isinstance(value, str):
306
- if verbose:
307
- logger.info(msg=f"Found existing XMP data in source: {key}")
461
+ return value.decode('utf-8', errors='ignore')
462
+ if isinstance(value, str):
308
463
  return value
309
- if verbose:
310
- logger.info("Generated new XMP data from EXIF")
311
464
  return create_xmp_from_exif(exif)
312
465
 
313
466
 
314
- def _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger):
467
+ def _add_exif_tags_to_pnginfo(exif, pnginfo):
468
+ camera_tags = {
469
+ 271: 'CameraMake', 272: 'CameraModel', 305: 'Software',
470
+ 306: 'DateTime', 315: 'Artist', 33432: 'Copyright'
471
+ }
472
+ exposure_tags = {
473
+ 33434: 'ExposureTime', 33437: 'FNumber', 34855: 'ISOSpeed',
474
+ 37377: 'ShutterSpeedValue', 37378: 'ApertureValue', 37386: 'FocalLength',
475
+ 42036: 'LensModel', 37380: 'ExposureBiasValue', 37381: 'MaxApertureValue',
476
+ 37383: 'MeteringMode', 37385: 'Flash', 41987: 'WhiteBalance',
477
+ 41986: 'ExposureMode', 41990: 'SceneCaptureType', 36867: 'DateTimeOriginal'
478
+ }
315
479
  for tag_id, value in exif.items():
316
480
  if value is None:
317
481
  continue
318
482
  if isinstance(tag_id, int):
319
- _add_exif_tag(pnginfo, tag_id, value, verbose, logger)
483
+ if tag_id in camera_tags:
484
+ _add_typed_tag(pnginfo, f"EXIF_{camera_tags[tag_id]}", value)
485
+ elif tag_id in exposure_tags:
486
+ _add_typed_tag(pnginfo, f"EXIF_{exposure_tags[tag_id]}", value)
487
+ else:
488
+ _add_exif_tag(pnginfo, tag_id, value)
320
489
  elif isinstance(tag_id, str) and not tag_id.lower().startswith(('xmp', 'xml')):
321
- _add_png_text_tag(pnginfo, tag_id, value, verbose, logger)
490
+ _add_png_text_tag(pnginfo, tag_id, value)
491
+
492
+
493
+ def _add_typed_tag(pnginfo, key, value):
494
+ try:
495
+ if hasattr(value, 'numerator'):
496
+ stored_value = f"RATIONAL:{value.numerator}/{value.denominator}"
497
+ elif isinstance(value, bytes):
498
+ try:
499
+ stored_value = f"STRING:{value.decode('utf-8', errors='replace')}"
500
+ except Exception:
501
+ stored_value = f"BYTES:{str(value)[:100]}"
502
+ elif isinstance(value, (list, tuple)):
503
+ stored_value = f"ARRAY:{','.join(str(x) for x in value)}"
504
+ elif isinstance(value, int):
505
+ stored_value = f"INT:{value}"
506
+ elif isinstance(value, float):
507
+ stored_value = f"FLOAT:{value}"
508
+ else:
509
+ stored_value = f"STRING:{str(value)}"
510
+ pnginfo.add_text(key, stored_value)
511
+ except Exception:
512
+ pass
322
513
 
323
514
 
324
- def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
515
+ def _add_exif_tag(pnginfo, tag_id, value):
325
516
  try:
326
517
  tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
327
518
  if isinstance(value, bytes) and len(value) > 1000:
@@ -334,17 +525,16 @@ def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
334
525
  pnginfo.add_text(tag_name, decoded_value)
335
526
  except Exception:
336
527
  pass
337
- elif hasattr(value, 'numerator'): # IFDRational
528
+ elif hasattr(value, 'numerator'):
338
529
  rational_str = f"{value.numerator}/{value.denominator}"
339
530
  pnginfo.add_text(tag_name, rational_str)
340
531
  else:
341
532
  pnginfo.add_text(tag_name, str(value))
342
- except Exception as e:
343
- if verbose:
344
- logger.warning(f"Could not store EXIF tag {tag_id}: {e}")
533
+ except Exception:
534
+ pass
345
535
 
346
536
 
347
- def _add_png_text_tag(pnginfo, key, value, verbose, logger):
537
+ def _add_png_text_tag(pnginfo, key, value):
348
538
  try:
349
539
  clean_key = key[4:] if key.startswith('PNG_') else key
350
540
  if 'icc' in clean_key.lower() or 'profile' in clean_key.lower():
@@ -358,40 +548,97 @@ def _add_png_text_tag(pnginfo, key, value, verbose, logger):
358
548
  pnginfo.add_text(clean_key, truncated_value)
359
549
  else:
360
550
  pnginfo.add_text(clean_key, str(value))
361
- except Exception as e:
362
- if verbose:
363
- logger.warning(msg=f"Could not store PNG metadata {key}: {e}")
551
+ except Exception:
552
+ pass
364
553
 
365
554
 
366
- def _extract_icc_profile(exif, verbose, logger):
555
+ def _extract_icc_profile(exif):
367
556
  for key, value in exif.items():
368
557
  if (isinstance(key, str) and
369
558
  isinstance(value, bytes) and
370
559
  ('icc' in key.lower() or 'profile' in key.lower())):
371
- if verbose:
372
- logger.info(f"Found ICC profile: {key}")
373
560
  return value
374
561
  return None
375
562
 
376
563
 
377
- def _save_png_with_metadata(pil_image, out_filename, pnginfo, icc_profile, verbose, logger):
564
+ def clean_data_for_tiff(data):
565
+ if isinstance(data, str):
566
+ return data.encode('ascii', 'ignore').decode('ascii')
567
+ if isinstance(data, bytes):
568
+ decoded = data.decode('utf-8', 'ignore')
569
+ return decoded.encode('ascii', 'ignore').decode('ascii')
570
+ if isinstance(data, IFDRational):
571
+ return (data.numerator, data.denominator)
572
+ return data
573
+
574
+
575
+ def write_image_with_exif_data_jpg(exif, image, out_filename, verbose):
576
+ cv2.imwrite(out_filename, image, [cv2.IMWRITE_JPEG_QUALITY, 100])
577
+ add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
578
+
579
+
580
+ def exif_extra_tags_for_tif(exif):
581
+ res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
582
+ resolution = (
583
+ (res_x.numerator, res_x.denominator),
584
+ (res_y.numerator, res_y.denominator)
585
+ ) if res_x and res_y else (
586
+ (720000, 10000), (720000, 10000)
587
+ )
588
+ exif_tags = {
589
+ 'resolution': resolution,
590
+ 'resolutionunit': exif.get(RESOLUTIONUNIT, 'inch'),
591
+ 'software': clean_data_for_tiff(exif.get(SOFTWARE)) or constants.APP_TITLE,
592
+ 'photometric': exif.get(PHOTOMETRICINTERPRETATION)
593
+ }
594
+ extra = []
595
+ for tag_id in exif:
596
+ tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
597
+ if tag in NO_COPY_TIFF_TAGS or tag_id in NO_COPY_TIFF_TAGS_ID or tag_id == SOFTWARE:
598
+ continue
599
+ if isinstance(data, IFDRational):
600
+ data = (data.numerator, data.denominator) if data.denominator != 0 else (0, 1)
601
+ extra.append((tag_id, 5, 1, data, False))
602
+ continue
603
+ processed_data = _process_tiff_data(data)
604
+ if processed_data:
605
+ dtype, count, data_value = processed_data
606
+ extra.append((tag_id, dtype, count, data_value, False))
607
+ return extra, exif_tags
608
+
609
+
610
+ def _process_tiff_data(data):
611
+ if isinstance(data, IFDRational):
612
+ data = (data.numerator, data.denominator) if data.denominator != 0 else (0, 1)
613
+ return 5, 1, data
614
+ if hasattr(data, '__iter__') and not isinstance(data, (str, bytes)):
615
+ try:
616
+ clean_data = [float(x)
617
+ if not hasattr(x, 'denominator') or x.denominator != 0
618
+ else float('nan') for x in data]
619
+ return 12, len(clean_data), tuple(clean_data)
620
+ except Exception:
621
+ return None
622
+ if isinstance(data, (str, bytes)):
623
+ clean_data = clean_data_for_tiff(data)
624
+ if clean_data:
625
+ return 2, len(clean_data) + 1, clean_data
626
+ try:
627
+ dtype, count = get_tiff_dtype_count(data)
628
+ return dtype, count, data
629
+ except Exception:
630
+ return None
631
+
632
+
633
+ def write_image_with_exif_data_tif(exif, image, out_filename):
634
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
378
635
  try:
379
- save_args = {'format': 'PNG', 'pnginfo': pnginfo}
380
- if icc_profile:
381
- save_args['icc_profile'] = icc_profile
382
- if verbose:
383
- logger.info(msg="Saved PNG with ICC profile and metadata")
384
- else:
385
- if verbose:
386
- logger.info(msg="Saved PNG without ICC profile but with metadata")
387
- pil_image.save(out_filename, **save_args)
388
- if verbose:
389
- logger.info(msg=f"Successfully wrote PNG with metadata: {out_filename}")
390
- except Exception as e:
391
- if verbose:
392
- logger.error(msg=f"Failed to write PNG with metadata: {e}")
393
- logger.error(traceback.format_exc())
394
- pil_image.save(out_filename, format='PNG')
636
+ metadata = {"description": f"image generated with {constants.APP_STRING} package"}
637
+ extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
638
+ tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
639
+ extratags=extra_tags, **exif_tags)
640
+ except Exception:
641
+ tifffile.imwrite(out_filename, image, compression='adobe_deflate')
395
642
 
396
643
 
397
644
  def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_order='auto'):
@@ -401,13 +648,9 @@ def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_o
401
648
  if verbose:
402
649
  print_exif(exif)
403
650
  if extension_jpg(out_filename):
404
- cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
405
- add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
651
+ write_image_with_exif_data_jpg(exif, image, out_filename, verbose)
406
652
  elif extension_tif(out_filename):
407
- metadata = {"description": f"image generated with {constants.APP_STRING} package"}
408
- extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
409
- tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
410
- extratags=extra_tags, **exif_tags)
653
+ write_image_with_exif_data_tif(exif, image, out_filename)
411
654
  elif extension_png(out_filename):
412
655
  write_image_with_exif_data_png(exif, image, out_filename, verbose, color_order=color_order)
413
656
  return exif
@@ -420,21 +663,21 @@ def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
420
663
  raise RuntimeError('No exif data provided.')
421
664
  if verbose:
422
665
  print_exif(exif)
423
- if extension_tif(in_filename):
424
- image_new = tifffile.imread(in_filename)
425
- elif extension_jpg(in_filename):
426
- image_new = Image.open(in_filename)
427
- elif extension_png(in_filename):
428
- image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
429
- if extension_jpg(in_filename):
666
+ if extension_png(in_filename) or extension_tif(in_filename):
667
+ if extension_tif(in_filename):
668
+ image_new = tifffile.imread(in_filename)
669
+ elif extension_png(in_filename):
670
+ image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
671
+ if extension_tif(in_filename):
672
+ metadata = {"description": f"image generated with {constants.APP_STRING} package"}
673
+ extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
674
+ tifffile.imwrite(
675
+ out_filename, image_new, metadata=metadata, compression='adobe_deflate',
676
+ extratags=extra_tags, **exif_tags)
677
+ elif extension_png(in_filename):
678
+ write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
679
+ else:
430
680
  add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose)
431
- elif extension_tif(in_filename):
432
- metadata = {"description": f"image generated with {constants.APP_STRING} package"}
433
- extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
434
- tifffile.imwrite(out_filename, image_new, metadata=metadata, compression='adobe_deflate',
435
- extratags=extra_tags, **exif_tags)
436
- elif extension_png(in_filename):
437
- write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
438
681
  return exif
439
682
 
440
683
 
@@ -447,40 +690,40 @@ def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, v
447
690
  return save_exif_data(exif, in_filename, out_filename, verbose)
448
691
 
449
692
 
450
- def exif_dict(exif, hide_xml=True):
451
- if exif is None:
693
+ def exif_dict(exif_data):
694
+ if exif_data is None:
452
695
  return None
453
- exif_data = {}
454
- for tag_id in exif:
455
- tag = TAGS.get(tag_id, tag_id)
456
- if tag_id == XMLPACKET and hide_xml:
457
- data = "<<< XML data >>>"
458
- elif tag_id in (IMAGERESOURCES, INTERCOLORPROFILE):
459
- data = "<<< Photoshop data >>>"
460
- elif tag_id == STRIPOFFSETS:
461
- data = "<<< Strip offsets >>>"
462
- elif tag_id == STRIPBYTECOUNTS:
463
- data = "<<< Strip byte counts >>>"
696
+ result = {}
697
+ for tag, value in exif_data.items():
698
+ if isinstance(tag, int):
699
+ tag_name = TAGS.get(tag, str(tag))
464
700
  else:
465
- data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
466
- if isinstance(data, bytes):
467
- try:
468
- data = data.decode()
469
- except Exception:
470
- pass
471
- exif_data[tag] = (tag_id, data)
472
- return exif_data
701
+ tag_name = str(tag)
702
+ if tag_name.startswith('PNG_EXIF_'):
703
+ standard_tag = tag_name[9:]
704
+ elif tag_name.startswith('EXIF_'):
705
+ standard_tag = tag_name[5:]
706
+ elif tag_name.startswith('PNG_'):
707
+ continue
708
+ else:
709
+ standard_tag = tag_name
710
+ result[standard_tag] = (tag, value)
711
+ return result
473
712
 
474
713
 
475
- def print_exif(exif, hide_xml=True):
476
- exif_data = exif_dict(exif, hide_xml)
714
+ def print_exif(exif):
715
+ exif_data = exif_dict(exif)
477
716
  if exif_data is None:
478
717
  raise RuntimeError('Image has no exif data.')
479
718
  logger = logging.getLogger(__name__)
480
719
  for tag, (tag_id, data) in exif_data.items():
481
720
  if isinstance(data, IFDRational):
482
721
  data = f"{data.numerator}/{data.denominator}"
722
+ data_str = f"{data}"
723
+ if len(data_str) > 40:
724
+ data_str = f"{data_str[:40]}... (truncated)"
483
725
  if isinstance(tag_id, int):
484
- logger.info(msg=f"{tag:25} [#{tag_id:5d}]: {data}")
726
+ tag_id_str = f"[#{tag_id:5d}]"
485
727
  else:
486
- logger.info(msg=f"{tag:25} [ {tag_id:20} ]: {str(data)[:100]}...")
728
+ tag_id_str = f"[ {tag_id:20} ]"
729
+ logger.info(msg=f"{tag:25} {tag_id_str}: {data_str}")