Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated EPS mode when opening images without transparency #8281

Merged
merged 14 commits into from
Oct 6, 2024
Binary file added Tests/images/eps/1.bmp
Binary file not shown.
File renamed without changes.
Binary file added Tests/images/eps/1_atend.eps
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes
File renamed without changes.
File renamed without changes.
File renamed without changes
File renamed without changes.
File renamed without changes
File renamed without changes
File renamed without changes.
92 changes: 53 additions & 39 deletions Tests/test_file_eps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from PIL import EpsImagePlugin, Image, UnidentifiedImageError, features

from .helper import (
assert_image_equal_tofile,
assert_image_similar,
assert_image_similar_tofile,
hopper,
Expand All @@ -19,18 +20,18 @@
HAS_GHOSTSCRIPT = EpsImagePlugin.has_ghostscript()

# Our two EPS test files (they are identical except for their bounding boxes)
FILE1 = "Tests/images/zero_bb.eps"
FILE2 = "Tests/images/non_zero_bb.eps"
FILE1 = "Tests/images/eps/zero_bb.eps"
FILE2 = "Tests/images/eps/non_zero_bb.eps"

# Due to palletization, we'll need to convert these to RGB after load
FILE1_COMPARE = "Tests/images/zero_bb.png"
FILE1_COMPARE_SCALE2 = "Tests/images/zero_bb_scale2.png"
FILE1_COMPARE = "Tests/images/eps/zero_bb.png"
FILE1_COMPARE_SCALE2 = "Tests/images/eps/zero_bb_scale2.png"

FILE2_COMPARE = "Tests/images/non_zero_bb.png"
FILE2_COMPARE_SCALE2 = "Tests/images/non_zero_bb_scale2.png"
FILE2_COMPARE = "Tests/images/eps/non_zero_bb.png"
FILE2_COMPARE_SCALE2 = "Tests/images/eps/non_zero_bb_scale2.png"

# EPS test files with binary preview
FILE3 = "Tests/images/binary_preview_map.eps"
FILE3 = "Tests/images/eps/binary_preview_map.eps"

# Three unsigned 32bit little-endian values:
# 0xC6D3D0C5 magic number
Expand Down Expand Up @@ -126,6 +127,15 @@ def test_binary_header_only() -> None:
EpsImagePlugin.EpsImageFile(data)


@pytest.mark.parametrize("prefix", (b"", simple_binary_header))
def test_simple_eps_file(prefix: bytes) -> None:
data = io.BytesIO(prefix + b"\n".join(simple_eps_file))
with Image.open(data) as img:
assert img.mode == "RGB"
assert img.size == (100, 100)
assert img.format == "EPS"


@pytest.mark.parametrize("prefix", (b"", simple_binary_header))
def test_missing_version_comment(prefix: bytes) -> None:
data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_version))
Expand All @@ -141,23 +151,19 @@ def test_missing_boundingbox_comment(prefix: bytes) -> None:


@pytest.mark.parametrize("prefix", (b"", simple_binary_header))
def test_invalid_boundingbox_comment(prefix: bytes) -> None:
data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox))
@pytest.mark.parametrize(
"file_lines",
(
simple_eps_file_with_invalid_boundingbox,
simple_eps_file_with_invalid_boundingbox_valid_imagedata,
),
)
def test_invalid_boundingbox_comment(prefix: bytes, file_lines: list[bytes]) -> None:
Yay295 marked this conversation as resolved.
Show resolved Hide resolved
data = io.BytesIO(prefix + b"\n".join(file_lines))
with pytest.raises(OSError, match="cannot determine EPS bounding box"):
EpsImagePlugin.EpsImageFile(data)


@pytest.mark.parametrize("prefix", (b"", simple_binary_header))
def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix: bytes) -> None:
data = io.BytesIO(
prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox_valid_imagedata)
)
with Image.open(data) as img:
assert img.mode == "RGB"
assert img.size == (100, 100)
assert img.format == "EPS"


@pytest.mark.parametrize("prefix", (b"", simple_binary_header))
def test_ascii_comment_too_long(prefix: bytes) -> None:
data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_ascii_comment))
Expand All @@ -177,7 +183,7 @@ def test_load_long_binary_data(prefix: bytes) -> None:
data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data))
with Image.open(data) as img:
img.load()
assert img.mode == "RGB"
assert img.mode == "1"
assert img.size == (100, 100)
assert img.format == "EPS"

Expand All @@ -187,7 +193,7 @@ def test_load_long_binary_data(prefix: bytes) -> None:
)
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
def test_cmyk() -> None:
with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image:
with Image.open("Tests/images/eps/pil_sample_cmyk.eps") as cmyk_image:
assert cmyk_image.mode == "CMYK"
assert cmyk_image.size == (100, 100)
assert cmyk_image.format == "EPS"
Expand All @@ -204,8 +210,8 @@ def test_cmyk() -> None:
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
def test_showpage() -> None:
# See https://github.com/python-pillow/Pillow/issues/2615
with Image.open("Tests/images/reqd_showpage.eps") as plot_image:
with Image.open("Tests/images/reqd_showpage.png") as target:
with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image:
with Image.open("Tests/images/eps/reqd_showpage.png") as target:
# should not crash/hang
plot_image.load()
# fonts could be slightly different
Expand All @@ -214,11 +220,11 @@ def test_showpage() -> None:

@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
def test_transparency() -> None:
with Image.open("Tests/images/reqd_showpage.eps") as plot_image:
with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image:
plot_image.load(transparency=True)
assert plot_image.mode == "RGBA"

with Image.open("Tests/images/reqd_showpage_transparency.png") as target:
with Image.open("Tests/images/eps/reqd_showpage_transparency.png") as target:
# fonts could be slightly different
assert_image_similar(plot_image, target, 6)

Expand All @@ -245,9 +251,15 @@ def test_bytesio_object() -> None:
assert_image_similar(img, image1_scale1_compare, 5)


def test_1_mode() -> None:
with Image.open("Tests/images/1.eps") as im:
assert im.mode == "1"
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
@pytest.mark.parametrize(
# These images have an "ImageData" descriptor.
"filename",
("Tests/images/eps/1.eps", "Tests/images/eps/1_atend.eps"),
)
def test_1(filename: str) -> None:
with Image.open(filename) as im:
assert_image_equal_tofile(im, "Tests/images/eps/1.bmp")


def test_image_mode_not_supported(tmp_path: Path) -> None:
Expand Down Expand Up @@ -302,7 +314,9 @@ def test_render_scale2() -> None:


@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
@pytest.mark.parametrize("filename", (FILE1, FILE2, "Tests/images/illu10_preview.eps"))
@pytest.mark.parametrize(
"filename", (FILE1, FILE2, "Tests/images/eps/illu10_preview.eps")
)
def test_resize(filename: str) -> None:
with Image.open(filename) as im:
new_size = (100, 100)
Expand Down Expand Up @@ -344,10 +358,10 @@ def test_readline(prefix: bytes, line_ending: bytes) -> None:
@pytest.mark.parametrize(
"filename",
(
"Tests/images/illu10_no_preview.eps",
"Tests/images/illu10_preview.eps",
"Tests/images/illuCS6_no_preview.eps",
"Tests/images/illuCS6_preview.eps",
"Tests/images/eps/illu10_no_preview.eps",
"Tests/images/eps/illu10_preview.eps",
"Tests/images/eps/illuCS6_no_preview.eps",
"Tests/images/eps/illuCS6_preview.eps",
),
)
def test_open_eps(filename: str) -> None:
Expand All @@ -359,7 +373,7 @@ def test_open_eps(filename: str) -> None:
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
def test_emptyline() -> None:
# Test file includes an empty line in the header data
emptyline_file = "Tests/images/zero_bb_emptyline.eps"
emptyline_file = "Tests/images/eps/zero_bb_emptyline.eps"

with Image.open(emptyline_file) as image:
image.load()
Expand All @@ -371,7 +385,7 @@ def test_emptyline() -> None:
@pytest.mark.timeout(timeout=5)
@pytest.mark.parametrize(
"test_file",
["Tests/images/timeout-d675703545fee17acab56e5fec644c19979175de.eps"],
["Tests/images/eps/timeout-d675703545fee17acab56e5fec644c19979175de.eps"],
)
def test_timeout(test_file: str) -> None:
with open(test_file, "rb") as f:
Expand All @@ -384,20 +398,20 @@ def test_bounding_box_in_trailer() -> None:
# Check bounding boxes are parsed in the same way
# when specified in the header and the trailer
with (
Image.open("Tests/images/zero_bb_trailer.eps") as trailer_image,
Image.open("Tests/images/eps/zero_bb_trailer.eps") as trailer_image,
Image.open(FILE1) as header_image,
):
assert trailer_image.size == header_image.size


def test_eof_before_bounding_box() -> None:
with pytest.raises(OSError):
with Image.open("Tests/images/zero_bb_eof_before_boundingbox.eps"):
with Image.open("Tests/images/eps/zero_bb_eof_before_boundingbox.eps"):
pass


def test_invalid_data_after_eof() -> None:
with open("Tests/images/illuCS6_preview.eps", "rb") as f:
with open("Tests/images/eps/illuCS6_preview.eps", "rb") as f:
img_bytes = io.BytesIO(f.read() + b"\r\n%" + (b" " * 255))

with Image.open(img_bytes) as img:
Expand Down
8 changes: 4 additions & 4 deletions Tests/test_pickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non
),
("Tests/images/hopper.tif", None),
("Tests/images/test-card.png", None),
("Tests/images/zero_bb.png", None),
("Tests/images/zero_bb_scale2.png", None),
("Tests/images/non_zero_bb.png", None),
("Tests/images/non_zero_bb_scale2.png", None),
("Tests/images/eps/zero_bb.png", None),
("Tests/images/eps/zero_bb_scale2.png", None),
("Tests/images/eps/non_zero_bb.png", None),
("Tests/images/eps/non_zero_bb_scale2.png", None),
("Tests/images/p_trns_single.png", None),
("Tests/images/pil123p.png", None),
("Tests/images/itxt_chunks.png", None),
Expand Down
65 changes: 43 additions & 22 deletions src/PIL/EpsImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,13 @@
lengthfile -= len(s)
f.write(s)

device = "pngalpha" if transparency else "ppmraw"
if transparency:
# "RGBA"
device = "pngalpha"
else:
# "pnmraw" automatically chooses between
# PBM ("1"), PGM ("L"), and PPM ("RGB").
device = "pnmraw"

# Build Ghostscript command
command = [
Expand Down Expand Up @@ -151,8 +157,9 @@
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.check_call(command, startupinfo=startupinfo)
out_im = Image.open(outfile)
out_im.load()
with Image.open(outfile) as out_im:
out_im.load()
return out_im.im.copy()
finally:
try:
os.unlink(outfile)
Expand All @@ -161,10 +168,6 @@
except OSError:
pass

im = out_im.im.copy()
out_im.close()
return im


def _accept(prefix: bytes) -> bool:
return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5)
Expand All @@ -191,6 +194,11 @@

self._mode = "RGB"

# When reading header comments, the first comment is used.
# When reading trailer comments, the last comment is used.
bounding_box: list[int] | None = None
imagedata_size: tuple[int, int] | None = None

byte_arr = bytearray(255)
bytes_mv = memoryview(byte_arr)
bytes_read = 0
Expand All @@ -211,8 +219,8 @@
msg = 'EPS header missing "%%BoundingBox" comment'
raise SyntaxError(msg)

def _read_comment(s: str) -> bool:
nonlocal reading_trailer_comments
def read_comment(s: str) -> bool:
nonlocal bounding_box, reading_trailer_comments
try:
m = split.match(s)
except re.error as e:
Expand All @@ -227,18 +235,12 @@
if k == "BoundingBox":
if v == "(atend)":
reading_trailer_comments = True
elif not self.tile or (trailer_reached and reading_trailer_comments):
elif not bounding_box or (trailer_reached and reading_trailer_comments):
try:
# Note: The DSC spec says that BoundingBox
# fields should be integers, but some drivers
# put floating point values there anyway.
box = [int(float(i)) for i in v.split()]
self._size = box[2] - box[0], box[3] - box[1]
self.tile = [
ImageFile._Tile(
"eps", (0, 0) + self.size, offset, (length, box)
)
]
bounding_box = [int(float(i)) for i in v.split()]
except Exception:
pass
return True
Expand Down Expand Up @@ -289,7 +291,7 @@
continue

s = str(bytes_mv[:bytes_read], "latin-1")
if not _read_comment(s):
if not read_comment(s):
m = field.match(s)
if m:
k = m.group(1)
Expand All @@ -308,6 +310,12 @@
# Check for an "ImageData" descriptor
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096

# If we've already read an "ImageData" descriptor,
# don't read another one.
if imagedata_size:
bytes_read = 0
continue

Check warning on line 317 in src/PIL/EpsImagePlugin.py

View check run for this annotation

Codecov / codecov/patch

src/PIL/EpsImagePlugin.py#L316-L317

Added lines #L316 - L317 were not covered by tests

# Values:
# columns
# rows
Expand All @@ -333,22 +341,35 @@
else:
break

self._size = columns, rows
return
# Parse the columns and rows after checking the bit depth and mode
# in case the bit depth and/or mode are invalid.
imagedata_size = columns, rows
elif bytes_mv[:5] == b"%%EOF":
break
elif trailer_reached and reading_trailer_comments:
# Load EPS trailer
s = str(bytes_mv[:bytes_read], "latin-1")
_read_comment(s)
read_comment(s)
elif bytes_mv[:9] == b"%%Trailer":
trailer_reached = True
bytes_read = 0

if not self.tile:
# A "BoundingBox" is always required,
# even if an "ImageData" descriptor size exists.
Yay295 marked this conversation as resolved.
Show resolved Hide resolved
if not bounding_box:
msg = "cannot determine EPS bounding box"
raise OSError(msg)

# An "ImageData" size takes precedence over the "BoundingBox".
Yay295 marked this conversation as resolved.
Show resolved Hide resolved
self._size = imagedata_size or (
bounding_box[2] - bounding_box[0],
bounding_box[3] - bounding_box[1],
)

self.tile = [
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
]

def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
s = fp.read(4)

Expand Down
Loading