take into account kerning and inter-character spacing in bounding box
[PyX.git] / pyx / bitmap.py
blob1cae3f06e9962ff811ca5a065a71021c1f188162
1 # -*- encoding: utf-8 -*-
4 # Copyright (C) 2004-2013 André Wobst <wobsta@users.sourceforge.net>
5 # Copyright (C) 2011 Michael Schindler<m-schindler@users.sourceforge.net>
7 # This file is part of PyX (http://pyx.sourceforge.net/).
9 # PyX is free software; you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation; either version 2 of the License, or
12 # (at your option) any later version.
14 # PyX is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
19 # You should have received a copy of the GNU General Public License
20 # along with PyX; if not, write to the Free Software
21 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
23 import binascii, logging, struct
24 try:
25 import zlib
26 haszlib = True
27 except:
28 haszlib = False
30 from . import bbox, baseclasses, pswriter, pdfwriter, trafo, unit
32 logger = logging.getLogger("pyx")
34 devicenames = {"L": "/DeviceGray",
35 "RGB": "/DeviceRGB",
36 "CMYK": "/DeviceCMYK"}
37 decodestrings = {"L": "[0 1]",
38 "RGB": "[0 1 0 1 0 1]",
39 "CMYK": "[0 1 0 1 0 1 0 1]",
40 "P": "[0 255]"}
43 def ascii85lines(datalen):
44 if datalen < 4:
45 return 1
46 return (datalen + 56)/60
48 def ascii85stream(file, data):
49 """Encodes the string data in ASCII85 and writes it to
50 the stream file. The number of lines written to the stream
51 is known just from the length of the data by means of the
52 ascii85lines function. Note that the tailing newline character
53 of the last line is not added by this function, but it is taken
54 into account in the ascii85lines function."""
55 i = 3 # go on smoothly in case of data length equals zero
56 l = 0
57 l = [None, None, None, None]
58 for i in range(len(data)):
59 c = data[i]
60 l[i%4] = c
61 if i%4 == 3:
62 if i%60 == 3 and i != 3:
63 file.write("\n")
64 if l:
65 # instead of
66 # l[3], c5 = divmod(256*256*256*l[0]+256*256*l[1]+256*l[2]+l[3], 85)
67 # l[2], c4 = divmod(l[3], 85)
68 # we have to avoid number > 2**31 by
69 l[3], c5 = divmod(256*256*l[0]+256*256*l[1]+256*l[2]+l[3], 85)
70 l[2], c4 = divmod(256*256*3*l[0]+l[3], 85)
71 l[1], c3 = divmod(l[2], 85)
72 c1 , c2 = divmod(l[1], 85)
73 file.write_bytes(struct.pack("BBBBB", c1+33, c2+33, c3+33, c4+33, c5+33))
74 else:
75 file.write("z")
76 if i%4 != 3:
77 for j in range((i%4) + 1, 4):
78 l[j] = 0
79 l[3], c5 = divmod(256*256*l[0]+256*256*l[1]+256*l[2]+l[3], 85)
80 l[2], c4 = divmod(256*256*3*l[0]+l[3], 85)
81 l[1], c3 = divmod(l[2], 85)
82 c1 , c2 = divmod(l[1], 85)
83 file.write_bytes(struct.pack("BBBB", c1+33, c2+33, c3+33, c4+33)[:(i%4)+2])
85 _asciihexlinelength = 64
86 def asciihexlines(datalen):
87 return (datalen*2 + _asciihexlinelength - 1) / _asciihexlinelength
89 def asciihexstream(file, data):
90 hexdata = binascii.b2a_hex(data)
91 for i in range((len(hexdata)-1)/_asciihexlinelength + 1):
92 file.write(hexdata[i*_asciihexlinelength: i*_asciihexlinelength+_asciihexlinelength])
93 file.write("\n")
96 class image:
98 def __init__(self, width, height, mode, data, compressed=None, palette=None):
99 if width <= 0 or height <= 0:
100 raise ValueError("valid image size")
101 if mode not in ["L", "RGB", "CMYK", "LA", "RGBA", "CMYKA", "AL", "ARGB", "ACMYK"]:
102 raise ValueError("invalid mode")
103 if compressed is None and len(mode)*width*height != len(data):
104 raise ValueError("wrong size of uncompressed data")
105 self.size = width, height
106 self.mode = mode
107 self.data = data
108 self.compressed = compressed
109 self.palette = palette
111 def split(self):
112 if self.compressed is not None:
113 raise RuntimeError("cannot extract bands from compressed image")
114 bands = len(self.mode)
115 width, height = self.size
116 return [image(width, height, "L", bytes(self.data[band::bands])) for band in range(bands)]
118 def tobytes(self, *args):
119 if len(args):
120 raise RuntimeError("encoding not supported in this implementation")
121 return self.data
123 def convert(self, model):
124 raise RuntimeError("color model conversion not supported in this implementation")
127 class jpegimage(image):
129 def __init__(self, file):
130 try:
131 data = file.read()
132 except:
133 with open(file, "rb") as f:
134 data = f.read()
135 pos = 0
136 nestinglevel = 0
137 try:
138 while True:
139 if data[pos] == 0o377 and data[pos+1] not in [0, 0o377]:
140 # print("marker: 0x%02x \\%03o" % (data[pos+1], data[pos+1]))
141 if data[pos+1] == 0o330:
142 if not nestinglevel:
143 begin = pos
144 nestinglevel += 1
145 elif not nestinglevel:
146 raise ValueError("begin marker expected")
147 elif data[pos+1] == 0o331:
148 nestinglevel -= 1
149 if not nestinglevel:
150 end = pos + 2
151 break
152 elif data[pos+1] in [0o300, 0o302]:
153 l, bits, height, width, components = struct.unpack(">HBHHB", data[pos+2:pos+10])
154 if bits != 8:
155 raise ValueError("implementation limited to 8 bit per component only")
156 try:
157 mode = {1: "L", 3: "RGB", 4: "CMYK"}[components]
158 except KeyError:
159 raise ValueError("invalid number of components")
160 pos += l+1
161 elif data[pos+1] == 0o340:
162 l, id, major, minor, dpikind, xdpi, ydpi = struct.unpack(">H5sBBBHH", data[pos+2:pos+16])
163 if dpikind == 1:
164 self.info = {"dpi": (xdpi, ydpi)}
165 elif dpikind == 2:
166 self.info = {"dpi": (xdpi*2.54, ydpi*2.45)}
167 # else do not provide dpi information
168 pos += l+1
169 pos += 1
170 except IndexError:
171 raise ValueError("end marker expected")
172 image.__init__(self, width, height, mode, data[begin:end], compressed="DCT")
175 class PSimagedata(pswriter.PSresource):
177 def __init__(self, name, data, singlestring, maxstrlen):
178 pswriter.PSresource.__init__(self, "imagedata", name)
179 self.data = data
180 self.singlestring = singlestring
181 self.maxstrlen = maxstrlen
183 def output(self, file, writer, registry):
184 file.write("%%%%BeginRessource: %s\n" % self.id)
185 if self.singlestring:
186 file.write("%%%%BeginData: %i ASCII Lines\n"
187 "<~" % ascii85lines(len(self.data)))
188 ascii85stream(file, self.data)
189 file.write("~>\n"
190 "%%EndData\n")
191 else:
192 datalen = len(self.data)
193 tailpos = datalen - datalen % self.maxstrlen
194 file.write("%%%%BeginData: %i ASCII Lines\n" %
195 ((tailpos/self.maxstrlen) * ascii85lines(self.maxstrlen) +
196 ascii85lines(datalen-tailpos)))
197 file.write("[ ")
198 for i in range(0, tailpos, self.maxstrlen):
199 file.write("<~")
200 ascii85stream(file, self.data[i: i+self.maxstrlen])
201 file.write("~>\n")
202 if datalen != tailpos:
203 file.write("<~")
204 ascii85stream(file, self.data[tailpos:])
205 file.write("~>")
206 file.write("]\n"
207 "%%EndData\n")
208 file.write("/%s exch def\n" % self.id)
209 file.write("%%EndRessource\n")
212 class PDFimagepalettedata(pdfwriter.PDFobject):
214 def __init__(self, name, data):
215 pdfwriter.PDFobject.__init__(self, "imagepalettedata", name)
216 self.data = data
218 def write(self, file, writer, registry):
219 file.write("<<\n"
220 "/Length %d\n" % len(self.data))
221 file.write(">>\n"
222 "stream\n")
223 file.write(self.data)
224 file.write("\n"
225 "endstream\n")
228 class PDFimage(pdfwriter.PDFobject):
230 def __init__(self, name, width, height, palettemode, palettedata, mode,
231 bitspercomponent, compressmode, data, smask, registry, addresource=True):
232 pdfwriter.PDFobject.__init__(self, "image", name)
234 if addresource:
235 if palettedata is not None:
236 procset = "ImageI"
237 elif mode == "L":
238 procset = "ImageB"
239 else:
240 procset = "ImageC"
241 registry.addresource("XObject", name, self, procset=procset)
242 if palettedata is not None:
243 # note that acrobat wants a palette to be an object (which clearly is a bug)
244 self.PDFpalettedata = PDFimagepalettedata(name, palettedata)
245 registry.add(self.PDFpalettedata)
247 self.name = name
248 self.width = width
249 self.height = height
250 self.palettemode = palettemode
251 self.palettedata = palettedata
252 self.mode = mode
253 self.bitspercomponent = bitspercomponent
254 self.compressmode = compressmode
255 self.data = data
256 self.smask = smask
258 def write(self, file, writer, registry):
259 file.write("<<\n"
260 "/Type /XObject\n"
261 "/Subtype /Image\n"
262 "/Width %d\n" % self.width)
263 file.write("/Height %d\n" % self.height)
264 if self.palettedata is not None:
265 file.write("/ColorSpace [ /Indexed %s %i\n" % (devicenames[self.palettemode], len(self.palettedata)/3-1))
266 file.write("%d 0 R\n" % registry.getrefno(self.PDFpalettedata))
267 file.write("]\n")
268 else:
269 file.write("/ColorSpace %s\n" % devicenames[self.mode])
270 if self.smask:
271 file.write("/SMask %d 0 R\n" % registry.getrefno(self.smask))
272 file.write("/BitsPerComponent %d\n" % self.bitspercomponent)
273 file.write("/Length %d\n" % len(self.data))
274 if self.compressmode:
275 file.write("/Filter /%sDecode\n" % self.compressmode)
276 file.write(">>\n"
277 "stream\n")
278 file.write_bytes(self.data)
279 file.write("\n"
280 "endstream\n")
282 class bitmap_trafo(baseclasses.canvasitem):
284 def __init__(self, trafo, image,
285 PSstoreimage=0, PSmaxstrlen=4093, PSbinexpand=1,
286 compressmode="Flate", flatecompresslevel=6,
287 dctquality=75, dctoptimize=0, dctprogression=0):
288 self.pdftrafo = trafo
289 self.image = image
290 self.imagewidth, self.imageheight = image.size
292 self.PSstoreimage = PSstoreimage
293 self.PSmaxstrlen = PSmaxstrlen
294 self.PSbinexpand = PSbinexpand
295 self.compressmode = compressmode
296 self.flatecompresslevel = flatecompresslevel
297 self.dctquality = dctquality
298 self.dctoptimize = dctoptimize
299 self.dctprogression = dctprogression
301 try:
302 self.imagecompressed = image.compressed
303 except:
304 self.imagecompressed = None
305 if self.compressmode not in [None, "Flate", "DCT"]:
306 raise ValueError("invalid compressmode '%s'" % self.compressmode)
307 if self.imagecompressed not in [None, "Flate", "DCT"]:
308 raise ValueError("invalid compressed image '%s'" % self.imagecompressed)
309 if self.compressmode is not None and self.imagecompressed is not None:
310 raise ValueError("compression of a compressed image not supported")
311 if not haszlib and self.compressmode == "Flate":
312 logger.warning("zlib module not available, disable compression")
313 self.compressmode = None
315 def imagedata(self, interleavealpha):
316 """internal function
318 returns a tuple (mode, data, alpha, palettemode, palettedata)
319 where mode does not contain antialiasing anymore
322 alpha = palettemode = palettedata = None
323 data = self.image
324 mode = data.mode
325 if mode.startswith("A"):
326 mode = mode[1:]
327 if interleavealpha:
328 alpha = True
329 else:
330 bands = data.split()
331 alpha = bands[0]
332 data = image(self.imagewidth, self.imageheight, mode,
333 b"".join([bytes(values)
334 for values in zip(*[band.tobytes()
335 for band in bands[1:]])]), palette=data.palette)
336 if mode.endswith("A"):
337 bands = data.split()
338 bands = list(bands[-1:]) + list(bands[:-1])
339 mode = mode[:-1]
340 if interleavealpha:
341 alpha = True
342 # TODO: this is slow, but we don't want to depend on PIL or anything ... still, its incredibly slow to do it with lists and joins
343 data = image(self.imagewidth, self.imageheight, "A%s" % mode,
344 b"".join([bytes(values)
345 for values in zip(*[band.tobytes()
346 for band in bands])]), palette=data.palette)
347 else:
348 alpha = bands[0]
349 data = image(self.imagewidth, self.imageheight, mode,
350 b"".join([bytes(values)
351 for values in zip(*[band.tobytes()
352 for band in bands[1:]])]), palette=data.palette)
354 if mode == "P":
355 palettemode, palettedata = data.palette.getdata()
356 if palettemode not in ["L", "RGB", "CMYK"]:
357 logger.warning("image with unknown palette mode '%s' converted to rgb image" % palettemode)
358 data = data.convert("RGB")
359 mode = "RGB"
360 palettemode = None
361 palettedata = None
362 elif len(mode) == 1:
363 if mode != "L":
364 logger.warning("specific single channel image mode not natively supported, converted to regular grayscale")
365 data = data.convert("L")
366 mode = "L"
367 elif mode not in ["CMYK", "RGB"]:
368 logger.warning("image with unknown mode converted to rgb")
369 data = data.convert("RGB")
370 mode = "RGB"
372 if self.compressmode == "Flate":
373 data = zlib.compress(data.tobytes(), self.flatecompresslevel)
374 elif self.compressmode == "DCT":
375 data = data.tobytes("jpeg", mode, self.dctquality, self.dctoptimize, self.dctprogression)
376 else:
377 data = data.tobytes()
378 if alpha and not interleavealpha:
379 if self.compressmode == "Flate":
380 alpha = zlib.compress(alpha.tobytes(), self.flatecompresslevel)
381 elif self.compressmode == "DCT":
382 # well, this here is strange, we might want a alphacompressmode ...
383 alpha = alpha.tobytes("jpeg", mode, self.dctquality, self.dctoptimize, self.dctprogression)
384 else:
385 alpha = alpha.tobytes()
387 return mode, data, alpha, palettemode, palettedata
389 def bbox(self):
390 bb = bbox.empty()
391 bb.includepoint_pt(*self.pdftrafo.apply_pt(0.0, 0.0))
392 bb.includepoint_pt(*self.pdftrafo.apply_pt(0.0, 1.0))
393 bb.includepoint_pt(*self.pdftrafo.apply_pt(1.0, 0.0))
394 bb.includepoint_pt(*self.pdftrafo.apply_pt(1.0, 1.0))
395 return bb
397 def processPS(self, file, writer, context, registry, bbox):
398 mode, data, alpha, palettemode, palettedata = self.imagedata(True)
399 pstrafo = trafo.translate_pt(0, -1.0).scaled(self.imagewidth, -self.imageheight)*self.pdftrafo.inverse()
401 PSsinglestring = self.PSstoreimage and len(data) < self.PSmaxstrlen
402 if PSsinglestring:
403 PSimagename = "image-%d-%s-singlestring" % (id(self.image), self.compressmode)
404 else:
405 PSimagename = "image-%d-%s-stringarray" % (id(self.image), self.compressmode)
407 if self.PSstoreimage and not PSsinglestring:
408 registry.add(pswriter.PSdefinition("imagedataaccess",
409 b"{ /imagedataindex load " # get list index
410 b"dup 1 add /imagedataindex exch store " # store increased index
411 b"/imagedataid load exch get }")) # select string from array
412 if self.PSstoreimage:
413 registry.add(PSimagedata(PSimagename, data, PSsinglestring, self.PSmaxstrlen))
414 bbox += self.bbox()
416 file.write("gsave\n")
417 if palettedata is not None:
418 file.write("[ /Indexed %s %i\n" % (devicenames[palettemode], len(palettedata)/3-1))
419 file.write("%%%%BeginData: %i ASCII Lines\n" % ascii85lines(len(palettedata)))
420 file.write("<~")
421 ascii85stream(file, palettedata)
422 file.write("~>\n"
423 "%%EndData\n")
424 file.write("] setcolorspace\n")
425 else:
426 file.write("%s setcolorspace\n" % devicenames[mode])
428 if self.PSstoreimage and not PSsinglestring:
429 file.write("/imagedataindex 0 store\n" # not use the stack since interpreters differ in their stack usage
430 "/imagedataid %s store\n" % PSimagename)
432 file.write("<<\n")
433 if alpha:
434 file.write("/ImageType 3\n"
435 "/DataDict\n"
436 "<<\n")
437 file.write("/ImageType 1\n"
438 "/Width %i\n" % self.imagewidth)
439 file.write("/Height %i\n" % self.imageheight)
440 file.write("/BitsPerComponent 8\n"
441 "/ImageMatrix %s\n" % pstrafo)
442 file.write("/Decode %s\n" % decodestrings[mode])
444 file.write("/DataSource ")
445 if self.PSstoreimage:
446 if PSsinglestring:
447 file.write("/%s load" % PSimagename)
448 else:
449 file.write("/imagedataaccess load") # some printers do not allow for inline code here -> we store it in a resource
450 else:
451 if self.PSbinexpand == 2:
452 file.write("currentfile /ASCIIHexDecode filter")
453 else:
454 file.write("currentfile /ASCII85Decode filter")
455 if self.compressmode or self.imagecompressed:
456 file.write(" /%sDecode filter" % (self.compressmode or self.imagecompressed))
457 file.write("\n")
459 file.write(">>\n")
461 if alpha:
462 file.write("/MaskDict\n"
463 "<<\n"
464 "/ImageType 1\n"
465 "/Width %i\n" % self.imagewidth)
466 file.write("/Height %i\n" % self.imageheight)
467 file.write("/BitsPerComponent 8\n"
468 "/ImageMatrix %s\n" % pstrafo)
469 file.write("/Decode [1 0]\n"
470 ">>\n"
471 "/InterleaveType 1\n"
472 ">>\n")
474 if self.PSstoreimage:
475 file.write("image\n")
476 else:
477 if self.PSbinexpand == 2:
478 file.write("%%%%BeginData: %i ASCII Lines\n"
479 "image\n" % (asciihexlines(len(data)) + 1))
480 asciihexstream(file, data)
481 file.write(">\n")
482 else:
483 # the datasource is currentstream (plus some filters)
484 file.write("%%%%BeginData: %i ASCII Lines\n"
485 "image\n" % (ascii85lines(len(data)) + 1))
486 ascii85stream(file, data)
487 file.write("~>\n")
488 file.write("%%EndData\n")
490 file.write("grestore\n")
492 def processPDF(self, file, writer, context, registry, bbox):
493 mode, data, alpha, palettemode, palettedata = self.imagedata(False)
495 name = "image-%d-%s" % (id(self.image), self.compressmode or self.imagecompressed)
496 if alpha:
497 alpha = PDFimage("%s-smask" % name, self.imagewidth, self.imageheight,
498 None, None, "L", 8,
499 self.compressmode, alpha, None, registry, addresource=False)
500 registry.add(alpha)
501 registry.add(PDFimage(name, self.imagewidth, self.imageheight,
502 palettemode, palettedata, mode, 8,
503 self.compressmode or self.imagecompressed, data, alpha, registry))
505 bbox += self.bbox()
507 file.write("q\n")
508 self.pdftrafo.processPDF(file, writer, context, registry)
509 file.write("/%s Do\n" % name)
510 file.write("Q\n")
513 class bitmap_pt(bitmap_trafo):
515 def __init__(self, xpos_pt, ypos_pt, image, width_pt=None, height_pt=None, ratio=None, **kwargs):
516 imagewidth, imageheight = image.size
517 if width_pt is not None or height_pt is not None:
518 if width_pt is None:
519 if ratio is None:
520 width_pt = height_pt * imagewidth / float(imageheight)
521 else:
522 width_pt = ratio * height_pt
523 elif height_pt is None:
524 if ratio is None:
525 height_pt = width_pt * imageheight / float(imagewidth)
526 else:
527 height_pt = (1.0/ratio) * width_pt
528 elif ratio is not None:
529 raise ValueError("can't specify a ratio when setting width_pt and height_pt")
530 else:
531 if ratio is not None:
532 raise ValueError("must specify width_pt or height_pt to set a ratio")
533 widthdpi, heightdpi = image.info["dpi"] # fails when no dpi information available
534 width_pt = 72.0 * imagewidth / float(widthdpi)
535 height_pt = 72.0 * imageheight / float(heightdpi)
537 bitmap_trafo.__init__(self, trafo.trafo_pt(((float(width_pt), 0.0), (0.0, float(height_pt))), (float(xpos_pt), float(ypos_pt))), image, **kwargs)
540 class bitmap(bitmap_pt):
542 def __init__(self, xpos, ypos, image, width=None, height=None, **kwargs):
543 xpos_pt = unit.topt(xpos)
544 ypos_pt = unit.topt(ypos)
545 if width is not None:
546 width_pt = unit.topt(width)
547 else:
548 width_pt = None
549 if height is not None:
550 height_pt = unit.topt(height)
551 else:
552 height_pt = None
554 bitmap_pt.__init__(self, xpos_pt, ypos_pt, image, width_pt=width_pt, height_pt=height_pt, **kwargs)