1#
2# The Python Imaging Library.
3# $Id$
4#
5# EPS file handling
6#
7# History:
8# 1995-09-01 fl Created (0.1)
9# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
10# 1996-08-22 fl Don't choke on floating point BoundingBox values
11# 1996-08-23 fl Handle files from Macintosh (0.3)
12# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
13# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
14# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
15# resizing
16#
17# Copyright (c) 1997-2003 by Secret Labs AB.
18# Copyright (c) 1995-2003 by Fredrik Lundh
19#
20# See the README file for information on usage and redistribution.
21#
22from __future__ import annotations
23
24import io
25import os
26import re
27import subprocess
28import sys
29import tempfile
30from typing import IO
31
32from . import Image, ImageFile
33from ._binary import i32le as i32
34
35# --------------------------------------------------------------------
36
37
38split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
39field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
40
41gs_binary: str | bool | None = None
42gs_windows_binary = None
43
44
45def has_ghostscript() -> bool:
46 global gs_binary, gs_windows_binary
47 if gs_binary is None:
48 if sys.platform.startswith("win"):
49 if gs_windows_binary is None:
50 import shutil
51
52 for binary in ("gswin32c", "gswin64c", "gs"):
53 if shutil.which(binary) is not None:
54 gs_windows_binary = binary
55 break
56 else:
57 gs_windows_binary = False
58 gs_binary = gs_windows_binary
59 else:
60 try:
61 subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
62 gs_binary = "gs"
63 except OSError:
64 gs_binary = False
65 return gs_binary is not False
66
67
68def Ghostscript(
69 tile: list[ImageFile._Tile],
70 size: tuple[int, int],
71 fp: IO[bytes],
72 scale: int = 1,
73 transparency: bool = False,
74) -> Image.core.ImagingCore:
75 """Render an image using Ghostscript"""
76 global gs_binary
77 if not has_ghostscript():
78 msg = "Unable to locate Ghostscript on paths"
79 raise OSError(msg)
80 assert isinstance(gs_binary, str)
81
82 # Unpack decoder tile
83 args = tile[0].args
84 assert isinstance(args, tuple)
85 length, bbox = args
86
87 # Hack to support hi-res rendering
88 scale = int(scale) or 1
89 width = size[0] * scale
90 height = size[1] * scale
91 # resolution is dependent on bbox and size
92 res_x = 72.0 * width / (bbox[2] - bbox[0])
93 res_y = 72.0 * height / (bbox[3] - bbox[1])
94
95 out_fd, outfile = tempfile.mkstemp()
96 os.close(out_fd)
97
98 infile_temp = None
99 if hasattr(fp, "name") and os.path.exists(fp.name):
100 infile = fp.name
101 else:
102 in_fd, infile_temp = tempfile.mkstemp()
103 os.close(in_fd)
104 infile = infile_temp
105
106 # Ignore length and offset!
107 # Ghostscript can read it
108 # Copy whole file to read in Ghostscript
109 with open(infile_temp, "wb") as f:
110 # fetch length of fp
111 fp.seek(0, io.SEEK_END)
112 fsize = fp.tell()
113 # ensure start position
114 # go back
115 fp.seek(0)
116 lengthfile = fsize
117 while lengthfile > 0:
118 s = fp.read(min(lengthfile, 100 * 1024))
119 if not s:
120 break
121 lengthfile -= len(s)
122 f.write(s)
123
124 if transparency:
125 # "RGBA"
126 device = "pngalpha"
127 else:
128 # "pnmraw" automatically chooses between
129 # PBM ("1"), PGM ("L"), and PPM ("RGB").
130 device = "pnmraw"
131
132 # Build Ghostscript command
133 command = [
134 gs_binary,
135 "-q", # quiet mode
136 f"-g{width:d}x{height:d}", # set output geometry (pixels)
137 f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
138 "-dBATCH", # exit after processing
139 "-dNOPAUSE", # don't pause between pages
140 "-dSAFER", # safe mode
141 f"-sDEVICE={device}",
142 f"-sOutputFile={outfile}", # output file
143 # adjust for image origin
144 "-c",
145 f"{-bbox[0]} {-bbox[1]} translate",
146 "-f",
147 infile, # input file
148 # showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
149 "-c",
150 "showpage",
151 ]
152
153 # push data through Ghostscript
154 try:
155 startupinfo = None
156 if sys.platform.startswith("win"):
157 startupinfo = subprocess.STARTUPINFO()
158 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
159 subprocess.check_call(command, startupinfo=startupinfo)
160 with Image.open(outfile) as out_im:
161 out_im.load()
162 return out_im.im.copy()
163 finally:
164 try:
165 os.unlink(outfile)
166 if infile_temp:
167 os.unlink(infile_temp)
168 except OSError:
169 pass
170
171
172def _accept(prefix: bytes) -> bool:
173 return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5)
174
175
176##
177# Image plugin for Encapsulated PostScript. This plugin supports only
178# a few variants of this format.
179
180
181class EpsImageFile(ImageFile.ImageFile):
182 """EPS File Parser for the Python Imaging Library"""
183
184 format = "EPS"
185 format_description = "Encapsulated Postscript"
186
187 mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
188
189 def _open(self) -> None:
190 (length, offset) = self._find_offset(self.fp)
191
192 # go to offset - start of "%!PS"
193 self.fp.seek(offset)
194
195 self._mode = "RGB"
196
197 # When reading header comments, the first comment is used.
198 # When reading trailer comments, the last comment is used.
199 bounding_box: list[int] | None = None
200 imagedata_size: tuple[int, int] | None = None
201
202 byte_arr = bytearray(255)
203 bytes_mv = memoryview(byte_arr)
204 bytes_read = 0
205 reading_header_comments = True
206 reading_trailer_comments = False
207 trailer_reached = False
208
209 def check_required_header_comments() -> None:
210 """
211 The EPS specification requires that some headers exist.
212 This should be checked when the header comments formally end,
213 when image data starts, or when the file ends, whichever comes first.
214 """
215 if "PS-Adobe" not in self.info:
216 msg = 'EPS header missing "%!PS-Adobe" comment'
217 raise SyntaxError(msg)
218 if "BoundingBox" not in self.info:
219 msg = 'EPS header missing "%%BoundingBox" comment'
220 raise SyntaxError(msg)
221
222 def read_comment(s: str) -> bool:
223 nonlocal bounding_box, reading_trailer_comments
224 try:
225 m = split.match(s)
226 except re.error as e:
227 msg = "not an EPS file"
228 raise SyntaxError(msg) from e
229
230 if not m:
231 return False
232
233 k, v = m.group(1, 2)
234 self.info[k] = v
235 if k == "BoundingBox":
236 if v == "(atend)":
237 reading_trailer_comments = True
238 elif not bounding_box or (trailer_reached and reading_trailer_comments):
239 try:
240 # Note: The DSC spec says that BoundingBox
241 # fields should be integers, but some drivers
242 # put floating point values there anyway.
243 bounding_box = [int(float(i)) for i in v.split()]
244 except Exception:
245 pass
246 return True
247
248 while True:
249 byte = self.fp.read(1)
250 if byte == b"":
251 # if we didn't read a byte we must be at the end of the file
252 if bytes_read == 0:
253 if reading_header_comments:
254 check_required_header_comments()
255 break
256 elif byte in b"\r\n":
257 # if we read a line ending character, ignore it and parse what
258 # we have already read. if we haven't read any other characters,
259 # continue reading
260 if bytes_read == 0:
261 continue
262 else:
263 # ASCII/hexadecimal lines in an EPS file must not exceed
264 # 255 characters, not including line ending characters
265 if bytes_read >= 255:
266 # only enforce this for lines starting with a "%",
267 # otherwise assume it's binary data
268 if byte_arr[0] == ord("%"):
269 msg = "not an EPS file"
270 raise SyntaxError(msg)
271 else:
272 if reading_header_comments:
273 check_required_header_comments()
274 reading_header_comments = False
275 # reset bytes_read so we can keep reading
276 # data until the end of the line
277 bytes_read = 0
278 byte_arr[bytes_read] = byte[0]
279 bytes_read += 1
280 continue
281
282 if reading_header_comments:
283 # Load EPS header
284
285 # if this line doesn't start with a "%",
286 # or does start with "%%EndComments",
287 # then we've reached the end of the header/comments
288 if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
289 check_required_header_comments()
290 reading_header_comments = False
291 continue
292
293 s = str(bytes_mv[:bytes_read], "latin-1")
294 if not read_comment(s):
295 m = field.match(s)
296 if m:
297 k = m.group(1)
298 if k[:8] == "PS-Adobe":
299 self.info["PS-Adobe"] = k[9:]
300 else:
301 self.info[k] = ""
302 elif s[0] == "%":
303 # handle non-DSC PostScript comments that some
304 # tools mistakenly put in the Comments section
305 pass
306 else:
307 msg = "bad EPS header"
308 raise OSError(msg)
309 elif bytes_mv[:11] == b"%ImageData:":
310 # Check for an "ImageData" descriptor
311 # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
312
313 # If we've already read an "ImageData" descriptor,
314 # don't read another one.
315 if imagedata_size:
316 bytes_read = 0
317 continue
318
319 # Values:
320 # columns
321 # rows
322 # bit depth (1 or 8)
323 # mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
324 # number of padding channels
325 # block size (number of bytes per row per channel)
326 # binary/ascii (1: binary, 2: ascii)
327 # data start identifier (the image data follows after a single line
328 # consisting only of this quoted value)
329 image_data_values = byte_arr[11:bytes_read].split(None, 7)
330 columns, rows, bit_depth, mode_id = (
331 int(value) for value in image_data_values[:4]
332 )
333
334 if bit_depth == 1:
335 self._mode = "1"
336 elif bit_depth == 8:
337 try:
338 self._mode = self.mode_map[mode_id]
339 except ValueError:
340 break
341 else:
342 break
343
344 # Parse the columns and rows after checking the bit depth and mode
345 # in case the bit depth and/or mode are invalid.
346 imagedata_size = columns, rows
347 elif bytes_mv[:5] == b"%%EOF":
348 break
349 elif trailer_reached and reading_trailer_comments:
350 # Load EPS trailer
351 s = str(bytes_mv[:bytes_read], "latin-1")
352 read_comment(s)
353 elif bytes_mv[:9] == b"%%Trailer":
354 trailer_reached = True
355 bytes_read = 0
356
357 # A "BoundingBox" is always required,
358 # even if an "ImageData" descriptor size exists.
359 if not bounding_box:
360 msg = "cannot determine EPS bounding box"
361 raise OSError(msg)
362
363 # An "ImageData" size takes precedence over the "BoundingBox".
364 self._size = imagedata_size or (
365 bounding_box[2] - bounding_box[0],
366 bounding_box[3] - bounding_box[1],
367 )
368
369 self.tile = [
370 ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
371 ]
372
373 def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
374 s = fp.read(4)
375
376 if s == b"%!PS":
377 # for HEAD without binary preview
378 fp.seek(0, io.SEEK_END)
379 length = fp.tell()
380 offset = 0
381 elif i32(s) == 0xC6D3D0C5:
382 # FIX for: Some EPS file not handled correctly / issue #302
383 # EPS can contain binary data
384 # or start directly with latin coding
385 # more info see:
386 # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
387 s = fp.read(8)
388 offset = i32(s)
389 length = i32(s, 4)
390 else:
391 msg = "not an EPS file"
392 raise SyntaxError(msg)
393
394 return length, offset
395
396 def load(
397 self, scale: int = 1, transparency: bool = False
398 ) -> Image.core.PixelAccess | None:
399 # Load EPS via Ghostscript
400 if self.tile:
401 self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
402 self._mode = self.im.mode
403 self._size = self.im.size
404 self.tile = []
405 return Image.Image.load(self)
406
407 def load_seek(self, pos: int) -> None:
408 # we can't incrementally load, so force ImageFile.parser to
409 # use our custom load method by defining this method.
410 pass
411
412
413# --------------------------------------------------------------------
414
415
416def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
417 """EPS Writer for the Python Imaging Library."""
418
419 # make sure image data is available
420 im.load()
421
422 # determine PostScript image mode
423 if im.mode == "L":
424 operator = (8, 1, b"image")
425 elif im.mode == "RGB":
426 operator = (8, 3, b"false 3 colorimage")
427 elif im.mode == "CMYK":
428 operator = (8, 4, b"false 4 colorimage")
429 else:
430 msg = "image mode is not supported"
431 raise ValueError(msg)
432
433 if eps:
434 # write EPS header
435 fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
436 fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
437 # fp.write("%%CreationDate: %s"...)
438 fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
439 fp.write(b"%%Pages: 1\n")
440 fp.write(b"%%EndComments\n")
441 fp.write(b"%%Page: 1 1\n")
442 fp.write(b"%%ImageData: %d %d " % im.size)
443 fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
444
445 # image header
446 fp.write(b"gsave\n")
447 fp.write(b"10 dict begin\n")
448 fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
449 fp.write(b"%d %d scale\n" % im.size)
450 fp.write(b"%d %d 8\n" % im.size) # <= bits
451 fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
452 fp.write(b"{ currentfile buf readhexstring pop } bind\n")
453 fp.write(operator[2] + b"\n")
454 if hasattr(fp, "flush"):
455 fp.flush()
456
457 ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size, 0, None)])
458
459 fp.write(b"\n%%%%EndBinary\n")
460 fp.write(b"grestore end\n")
461 if hasattr(fp, "flush"):
462 fp.flush()
463
464
465# --------------------------------------------------------------------
466
467
468Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
469
470Image.register_save(EpsImageFile.format, _save)
471
472Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
473
474Image.register_mime(EpsImageFile.format, "application/postscript")