Fix for newlines in exif tags, #513

This commit is contained in:
Rhet Turnbull
2021-08-29 12:18:20 -07:00
parent 8e2b768236
commit f0d7496bc6
7 changed files with 131 additions and 38 deletions

View File

@@ -1702,7 +1702,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline} {lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r' {cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n' {crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.42.77' {osxphotos_version} The osxphotos version, e.g. '0.42.78'
{osxphotos_cmd_line} The full command line used to run osxphotos {osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for The following substitutions may result in multiple values. Thus if specified for
@@ -3561,7 +3561,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}| |{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'| |{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'| |{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.42.77'| |{osxphotos_version}|The osxphotos version, e.g. '0.42.78'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos| |{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in| |{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder| |{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.42.77" __version__ = "0.42.78"

View File

@@ -7,6 +7,7 @@
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """ pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
import atexit import atexit
import html
import json import json
import logging import logging
import os import os
@@ -24,6 +25,24 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
EXIFTOOL_PROCESSES = [] EXIFTOOL_PROCESSES = []
def escape_str(s):
"""escape string for use with exiftool -E"""
if type(s) != str:
return s
s = html.escape(s)
s = s.replace("\n", "
")
s = s.replace("\t", "	")
s = s.replace("\r", "
")
return s
def unescape_str(s):
"""unescape an HTML string returned by exiftool -E"""
if type(s) != str:
return s
return html.unescape(s)
@atexit.register @atexit.register
def terminate_exiftool(): def terminate_exiftool():
"""Terminate any running ExifTool subprocesses; call this to cleanup when done using ExifTool""" """Terminate any running ExifTool subprocesses; call this to cleanup when done using ExifTool"""
@@ -110,6 +129,7 @@ class _ExifToolProc:
"-n", # no print conversion (e.g. print tag values in machine readable format) "-n", # no print conversion (e.g. print tag values in machine readable format)
"-P", # Preserve file modification date/time "-P", # Preserve file modification date/time
"-G", # print group name for each tag "-G", # print group name for each tag
"-E", # escape tag values for HTML (allows use of HTML 
 for newlines)
], ],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -189,6 +209,7 @@ class ExifTool:
if value is None: if value is None:
value = "" value = ""
value = escape_str(value)
command = [f"-{tag}={value}"] command = [f"-{tag}={value}"]
if self.overwrite and not self._context_mgr: if self.overwrite and not self._context_mgr:
command.append("-overwrite_original") command.append("-overwrite_original")
@@ -233,6 +254,7 @@ class ExifTool:
for value in values: for value in values:
if value is None: if value is None:
raise ValueError("Can't add None value to tag") raise ValueError("Can't add None value to tag")
value = escape_str(value)
command.append(f"-{tag}+={value}") command.append(f"-{tag}+={value}")
if self.overwrite and not self._context_mgr: if self.overwrite and not self._context_mgr:
@@ -335,6 +357,7 @@ class ExifTool:
json_str, _, _ = self.run_commands("-json") json_str, _, _ = self.run_commands("-json")
if not json_str: if not json_str:
return dict() return dict()
json_str = unescape_str(json_str.decode("utf-8"))
try: try:
exifdict = json.loads(json_str) exifdict = json.loads(json_str)
@@ -342,7 +365,6 @@ class ExifTool:
# will fail with some commands, e.g --ext AVI which produces # will fail with some commands, e.g --ext AVI which produces
# 'No file with specified extension' instead of json # 'No file with specified extension' instead of json
return dict() return dict()
exifdict = exifdict[0] exifdict = exifdict[0]
if not tag_groups: if not tag_groups:
# strip tag groups # strip tag groups
@@ -360,6 +382,7 @@ class ExifTool:
def json(self): def json(self):
"""returns JSON string containing all EXIF tags and values from exiftool""" """returns JSON string containing all EXIF tags and values from exiftool"""
json, _, _ = self.run_commands("-json") json, _, _ = self.run_commands("-json")
json = unescape_str(json.decode("utf-8"))
return json return json
def _read_exif(self): def _read_exif(self):
@@ -451,4 +474,3 @@ class _ExifToolCaching(ExifTool):
"""Clear cached data so that calls to json or asdict return fresh data""" """Clear cached data so that calls to json or asdict return fresh data"""
self._json_cache = None self._json_cache = None
self._asdict_cache = {} self._asdict_cache = {}

View File

@@ -1871,12 +1871,6 @@ def _exiftool_dict(
self.date_modified self.date_modified
).strftime("%Y:%m:%d %H:%M:%S") ).strftime("%Y:%m:%d %H:%M:%S")
# remove any new lines in any fields
for field, val in exif.items():
if type(val) == str:
exif[field] = val.replace("\n", " ")
elif type(val) == list:
exif[field] = [str(v).replace("\n", " ") for v in val if v is not None]
return exif return exif

View File

@@ -1376,12 +1376,12 @@ def test_no_adjustments(photosdb):
def test_exiftool_newlines_in_description(photosdb): def test_exiftool_newlines_in_description(photosdb):
"""Test that exiftool code removes newlines embedded in description, issue #393""" """Test that exiftool handles newlines embedded in description, issue #393"""
photo = photosdb.get_photo(UUID_DICT["description_newlines"]) photo = photosdb.get_photo(UUID_DICT["description_newlines"])
exif = photo._exiftool_dict() exif = photo._exiftool_dict()
assert photo.description.find("\n") > 0 assert photo.description.find("\n") > 0
assert exif["EXIF:ImageDescription"].find("\n") == -1 assert exif["EXIF:ImageDescription"].find("\n") > 0
@pytest.mark.skip(SKIP_TEST, reason="Not yet implemented") @pytest.mark.skip(SKIP_TEST, reason="Not yet implemented")

View File

@@ -141,6 +141,44 @@ def test_setvalue_1():
assert exif.data["IPTC:Keywords"] == "test" assert exif.data["IPTC:Keywords"] == "test"
def test_setvalue_multiline():
# test setting a tag value with embedded newline
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
exif = osxphotos.exiftool.ExifTool(tempfile)
exif.setvalue("EXIF:ImageDescription", "multi\nline")
assert not exif.error
exif._read_exif()
assert exif.data["EXIF:ImageDescription"] == "multi\nline"
def test_setvalue_non_alphanumeric_chars():
# test setting a tag value non-alphanumeric characters
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
exif = osxphotos.exiftool.ExifTool(tempfile)
exif.setvalue("EXIF:ImageDescription", "<hello>{world}$bye#foo%bar")
assert not exif.error
exif._read_exif()
assert exif.data["EXIF:ImageDescription"] == "<hello>{world}$bye#foo%bar"
def test_setvalue_warning(): def test_setvalue_warning():
# test setting illegal tag value generates warning # test setting illegal tag value generates warning
import os.path import os.path
@@ -311,6 +349,45 @@ def test_addvalues_2():
assert sorted(exif.data["IPTC:Keywords"]) == sorted(test_multi) assert sorted(exif.data["IPTC:Keywords"]) == sorted(test_multi)
def test_addvalues_non_alphanumeric_multiline():
# test setting a tag value
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
exif = osxphotos.exiftool.ExifTool(tempfile)
exif.addvalues("IPTC:Keywords", "multi\nline", "<Foo>\t{bar}")
assert not exif.error
exif._read_exif()
assert sorted(exif.data["IPTC:Keywords"]) == sorted(
["wedding", "multi\nline", "<Foo>\t{bar}"]
)
def test_addvalues_unicode():
# test setting a tag value with unicode
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
FileUtil.copy(TEST_FILE_ONE_KEYWORD, tempfile)
exif = osxphotos.exiftool.ExifTool(tempfile)
exif.setvalue("IPTC:Keywords", None)
exif.addvalues("IPTC:Keywords", "ǂ", "Ƕ")
assert not exif.error
exif._read_exif()
assert sorted(exif.data["IPTC:Keywords"]) == sorted(["ǂ", "Ƕ"])
def test_singleton(): def test_singleton():
import osxphotos.exiftool import osxphotos.exiftool

View File

@@ -1346,12 +1346,12 @@ def test_no_adjustments(photosdb):
def test_exiftool_newlines_in_description(photosdb): def test_exiftool_newlines_in_description(photosdb):
"""Test that exiftool code removes newlines embedded in description, issue #393""" """Test that exiftool handles newlines embedded in description, issue #393"""
photo = photosdb.get_photo(UUID_DICT["description_newlines"]) photo = photosdb.get_photo(UUID_DICT["description_newlines"])
exif = photo._exiftool_dict() exif = photo._exiftool_dict()
assert photo.description.find("\n") > 0 assert photo.description.find("\n") > 0
assert exif["EXIF:ImageDescription"].find("\n") == -1 assert exif["EXIF:ImageDescription"].find("\n") > 0
@pytest.mark.skip(SKIP_TEST, reason="Not yet implemented") @pytest.mark.skip(SKIP_TEST, reason="Not yet implemented")