Skip to content

Commit bdc1bf4

Browse files
authored
Always keep ICC profile and orientation to avoid display issues; keep copyright and artist fields by default (#103)
1 parent 2ed14f7 commit bdc1bf4

File tree

9 files changed

+290
-128
lines changed

9 files changed

+290
-128
lines changed

.github/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
changelog:
22
exclude:
33
authors:
4-
- dependabot
5-
- pre-commit-ci
4+
- dependabot[bot]
5+
- pre-commit-ci[bot]

src/exif_stripper/cli.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,29 @@ def main(argv: Sequence[str] | None = None) -> int:
4343
exif_options_group.add_argument(
4444
'--fields',
4545
nargs='+',
46-
choices=list(FieldGroup),
47-
default=FieldGroup.ALL,
46+
choices=[group for group in FieldGroup if group != FieldGroup.COPYRIGHT],
47+
default=(FieldGroup.ALL,),
4848
help=(
49-
'The fields to remove from the EXIF metadata. '
50-
'By default, all EXIF metadata is removed.'
49+
'The fields to remove from the EXIF metadata. By default, all EXIF metadata '
50+
'that can safely be deleted is removed.'
51+
),
52+
)
53+
exif_options_group.add_argument(
54+
'--remove-copyright',
55+
action='store_true',
56+
help=(
57+
'Whether to remove the image copyright information. '
58+
'By default, the artist and copyright tags will be preserved, if present.'
5159
),
5260
)
5361

5462
args = parser.parse_args(argv)
5563

56-
results = [process_image(filename, args.fields) for filename in args.filenames]
64+
fields = args.fields
65+
if args.remove_copyright:
66+
fields = [*fields, FieldGroup.COPYRIGHT]
67+
68+
results = [process_image(filename, fields=fields) for filename in args.filenames]
5769
return int(any(results))
5870

5971

src/exif_stripper/fields.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@
44

55
from PIL import ExifTags
66

7+
PRESERVE_FIELDS: tuple[IntEnum] = (
8+
ExifTags.Base.InterColorProfile,
9+
ExifTags.Base.Orientation,
10+
)
11+
"""Locations of EXIF information that should be preserved."""
12+
13+
OWNERSHIP_FIELDS: tuple[IntEnum] = (
14+
ExifTags.Base.Artist,
15+
ExifTags.Base.Copyright,
16+
)
17+
"""Locations of EXIF information related to copyright/ownership."""
18+
719

820
class FieldGroup(StrEnum):
921
"""Enum for groups of fields to target."""
@@ -14,6 +26,9 @@ class FieldGroup(StrEnum):
1426
CAMERA = auto()
1527
"""Field group for EXIF tags containing the make and model of the camera."""
1628

29+
COPYRIGHT = auto()
30+
"""Field group for EXIF tags related to the creator of the image."""
31+
1732
GPS = auto()
1833
"""Field group for all GPS information in EXIF metadata."""
1934

@@ -27,13 +42,14 @@ class FieldGroup(StrEnum):
2742
# References for EXIF tags:
2843
# - meanings: https://exiv2.org/tags.html
2944
# - enums: https://pillow.readthedocs.io/en/stable/_modules/PIL/ExifTags.html
30-
FIELDS: dict[FieldGroup, tuple[IntEnum]] = {
45+
EXIF_TAG_MAPPING: dict[FieldGroup, tuple[IntEnum]] = {
3146
FieldGroup.CAMERA: (
3247
ExifTags.Base.Make,
3348
ExifTags.Base.Model,
3449
ExifTags.Base.MakerNote,
3550
ExifTags.Base.MakerNoteSafety,
3651
),
52+
FieldGroup.COPYRIGHT: OWNERSHIP_FIELDS,
3753
FieldGroup.GPS: (ExifTags.IFD.GPSInfo,),
3854
FieldGroup.LENS: (ExifTags.Base.LensMake, ExifTags.Base.LensModel),
3955
FieldGroup.SERIALS: (

src/exif_stripper/processing.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
from __future__ import annotations
44

5+
import itertools
56
from contextlib import suppress
7+
from copy import deepcopy
68
from typing import TYPE_CHECKING
79

810
from PIL import Image, UnidentifiedImageError
911

1012
from .exceptions import UnknownFieldError
11-
from .fields import FIELDS, FieldGroup
13+
from .fields import EXIF_TAG_MAPPING, OWNERSHIP_FIELDS, PRESERVE_FIELDS, FieldGroup
1214

1315
if TYPE_CHECKING:
1416
import os
@@ -40,22 +42,38 @@ def process_image(
4042
suppress(FileNotFoundError, UnidentifiedImageError),
4143
Image.open(filename) as image,
4244
):
43-
if exif := image.getexif():
44-
if FieldGroup.ALL in fields:
45-
exif.clear()
46-
has_changed = True
47-
else:
48-
for field in fields:
49-
if locations := FIELDS.get(field):
50-
for location in locations:
51-
with suppress(KeyError):
52-
del exif[location]
53-
has_changed = True
54-
else:
55-
raise UnknownFieldError(field, FieldGroup)
56-
57-
if has_changed:
58-
image.save(filename, exif=exif)
59-
print(f'Stripped EXIF metadata from {filename}')
45+
exif = image.getexif()
46+
47+
if FieldGroup.ALL in fields:
48+
original_exif = deepcopy(exif)
49+
50+
fields_to_preserve = {
51+
location: value
52+
for location in itertools.chain(
53+
PRESERVE_FIELDS,
54+
OWNERSHIP_FIELDS if FieldGroup.COPYRIGHT not in fields else {},
55+
)
56+
if (value := exif.get(location))
57+
}
58+
59+
exif.clear()
60+
61+
for field, value in fields_to_preserve.items():
62+
exif[field] = value
63+
64+
has_changed = original_exif != exif
65+
else:
66+
for field in fields:
67+
if locations := EXIF_TAG_MAPPING.get(field):
68+
for location in locations:
69+
with suppress(KeyError):
70+
del exif[location]
71+
has_changed = True
72+
else:
73+
raise UnknownFieldError(field, FieldGroup)
74+
75+
if has_changed:
76+
image.save(filename, exif=exif)
77+
print(f'Stripped EXIF metadata from {filename}')
6078

6179
return has_changed

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Test suite for exif_stripper."""

tests/common.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Utility functions for testing."""
2+
3+
from PIL import Image
4+
5+
from exif_stripper.fields import (
6+
EXIF_TAG_MAPPING,
7+
OWNERSHIP_FIELDS,
8+
PRESERVE_FIELDS,
9+
FieldGroup,
10+
)
11+
12+
13+
def has_expected_metadata(filepath, fields, has_copyright, was_stripped) -> bool:
14+
"""Utility to check if a file has the expected metadata."""
15+
with Image.open(filepath) as im:
16+
exif = im.getexif()
17+
18+
preserved_fields_are_present = all(
19+
tag in exif for tag in PRESERVE_FIELDS
20+
) and has_copyright == all(tag in exif for tag in OWNERSHIP_FIELDS)
21+
22+
if fields is None or FieldGroup.ALL in fields:
23+
other_fields_present = all(
24+
tag in exif
25+
for field in set(FieldGroup).difference({FieldGroup.ALL})
26+
for tag in EXIF_TAG_MAPPING[field]
27+
)
28+
return preserved_fields_are_present and was_stripped != other_fields_present
29+
30+
return preserved_fields_are_present and was_stripped != all(
31+
tag in exif for field in fields for tag in EXIF_TAG_MAPPING[field]
32+
)

tests/conftest.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Common test fixtures."""
2+
3+
import pytest
4+
from PIL import ExifTags, Image
5+
6+
7+
@pytest.fixture
8+
def image_without_exif_data(tmp_path):
9+
"""Fixture for an image without EXIF data."""
10+
image_without_exif_data = tmp_path / 'clean.png'
11+
image_without_exif_data.touch()
12+
return image_without_exif_data
13+
14+
15+
@pytest.fixture
16+
def image_with_exif_data(tmp_path):
17+
"""Fixture for an image with only the required EXIF data."""
18+
image_file = tmp_path / 'test.png'
19+
with Image.new(mode='1', size=(2, 2)) as im:
20+
exif = im.getexif()
21+
22+
exif[ExifTags.Base.InterColorProfile] = 'some color profile'
23+
exif[ExifTags.Base.Orientation] = 1
24+
25+
im.save(image_file, exif=exif)
26+
27+
return image_file
28+
29+
30+
@pytest.fixture
31+
def image_with_full_exif_data(image_with_exif_data):
32+
"""Fixture for an image with EXIF data."""
33+
with Image.open(image_with_exif_data) as im:
34+
exif = im.getexif()
35+
36+
exif[ExifTags.Base.CameraOwnerName] = 'Unknown'
37+
38+
exif[ExifTags.Base.Artist] = 'Stefanie Molin'
39+
exif[ExifTags.Base.Copyright] = 'Copyright (c) Stefanie Molin.'
40+
41+
exif[ExifTags.IFD.GPSInfo] = {
42+
ExifTags.GPS.GPSVersionID: 1,
43+
ExifTags.GPS.GPSTrackRef: 1,
44+
}
45+
46+
exif[ExifTags.Base.Make] = 'SomeCameraMake'
47+
exif[ExifTags.Base.Model] = 'SomeCameraModel'
48+
exif[ExifTags.Base.MakerNote] = 'Some maker notes'
49+
exif[ExifTags.Base.MakerNoteSafety] = 1
50+
51+
exif[ExifTags.Base.LensMake] = 'SomeLensMake'
52+
exif[ExifTags.Base.LensModel] = 'SomeLensModel'
53+
54+
exif[ExifTags.Base.BodySerialNumber] = 'ABC123'
55+
exif[ExifTags.Base.CameraSerialNumber] = 'DEF456'
56+
exif[ExifTags.Base.LensSerialNumber] = 'GHI789'
57+
58+
im.save(image_with_exif_data, exif=exif)
59+
60+
return image_with_exif_data

0 commit comments

Comments
 (0)