1# -*- coding: utf-8 -*-2# Copyright 2011 Takeshi KOMIYA3#4# Licensed under the Apache License, Version 2.0 (the "License");5# you may not use this file except in compliance with the License.6# You may obtain a copy of the License at7#8# http://www.apache.org/licenses/LICENSE-2.09#10# Unless required by applicable law or agreed to in writing, software11# distributed under the License is distributed on an "AS IS" BASIS,12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.13# See the License for the specific language governing permissions and14# limitations under the License.1516from __future__ import division1718import math19import re20from functools import partial, wraps21from itertools import tee2223from PIL import Image, ImageDraw, ImageFilter, ImageFont2425from blockdiag.imagedraw import base26from blockdiag.imagedraw.utils import memoize27from blockdiag.imagedraw.utils.ellipse import dots as ellipse_dots28from blockdiag.utils import XY, Box, Size, images29from blockdiag.utils.fontmap import FontMap, parse_fontpath30from blockdiag.utils.myitertools import istep, stepslice3132# to support pillow < 9.1.033if not hasattr(Image, 'Resampling'):34 from enum import IntEnum3536 class Resampling(IntEnum):37 NEAREST = 038 BOX = 439 BILINEAR = 240 HAMMING = 541 BICUBIC = 342 LANCZOS = 14344 Image.Resampling = Resampling454647def point_pairs(xylist):48 iterable = iter(xylist)49 for pt in iterable:50 if isinstance(pt, int):51 yield (pt, next(iterable))52 else:53 yield pt545556def line_segments(xylist):57 p1, p2 = tee(point_pairs(xylist))58 next(p2)59 return zip(p1, p2)606162def dashize_line(line, length):63 pt1, pt2 = line64 if pt1[0] == pt2[0]: # holizonal65 if pt1[1] > pt2[1]:66 pt2, pt1 = line6768 r = stepslice(range(round(pt1[1]), round(pt2[1])), length)69 for y1, y2 in istep(n for n in r):70 yield [(pt1[0], y1), (pt1[0], y2)]7172 elif pt1[1] == pt2[1]: # vertical73 if pt1[0] > pt2[0]:74 pt2, pt1 = line7576 r = stepslice(range(round(pt1[0]), round(pt2[0])), length)77 for x1, x2 in istep(n for n in r):78 yield [(x1, pt1[1]), (x2, pt1[1])]79 else: # diagonal80 if pt1[0] > pt2[0]:81 pt2, pt1 = line8283 # DDA (Digital Differential Analyzer) Algorithm84 locus = []85 m = float(pt2[1] - pt1[1]) / float(pt2[0] - pt1[0])86 x = pt1[0]87 y = pt1[1]8889 while x <= pt2[0]:90 locus.append((int(x), int(round(y))))91 x += 192 y += m9394 for p1, p2 in istep(stepslice(locus, length)):95 yield (p1, p2)969798def style2cycle(style, thick):99 if thick is None:100 thick = 1101102 if style == 'dotted':103 length = [2 * thick, 2 * thick]104 elif style == 'dashed':105 length = [4 * thick, 4 * thick]106 elif style == 'none':107 length = [0, 65535 * thick]108 elif re.search(r'^\d+(,\d+)*$', style or ""):109 length = [int(n) * thick for n in style.split(',')]110 else:111 length = None112113 return length114115116def ttfont_for(font):117 if font.size <= 0:118 return None119 if font.path:120 path, index = parse_fontpath(font.path)121 if index:122 ttfont = ImageFont.truetype(path, font.size, index=index)123 else:124 ttfont = ImageFont.truetype(path, font.size)125 else:126 ttfont = None127128 return ttfont129130131class ImageDrawExBase(base.ImageDraw):132 def __init__(self, filename, **kwargs):133 self.filename = filename134 self.transparency = kwargs.get('transparency')135 self.bgcolor = kwargs.get('color', (256, 256, 256))136 self._image = None137 self.draw = None138139 if kwargs.get('parent'):140 self.scale_ratio = kwargs.get('parent').scale_ratio141 else:142 self.scale_ratio = kwargs.get('scale_ratio', 1)143144 self.set_canvas_size(Size(1, 1)) # This line make textsize() workable145146 def paste(self, image, pt, mask=None):147 self._image.paste(image, pt, mask)148 self.draw = ImageDraw.Draw(self._image)149150 def set_canvas_size(self, size):151 if self.transparency:152 mode = 'RGBA'153 else:154 mode = 'RGB'155156 self._image = Image.new(mode, size, self.bgcolor)157158 # set transparency to background159 if self.transparency:160 alpha = Image.new('L', size, 1)161 self._image.putalpha(alpha)162163 self.draw = ImageDraw.Draw(self._image)164165 def resizeCanvas(self, size):166 self._image = self._image.resize(size, Image.Resampling.LANCZOS)167 self.draw = ImageDraw.Draw(self._image)168169 def arc(self, box, start, end, **kwargs):170 style = kwargs.get('style')171 if 'style' in kwargs:172 del kwargs['style']173 if 'thick' in kwargs:174 del kwargs['thick']175176 if style:177 while start > end:178 end += 360179180 cycle = style2cycle(style, kwargs.get('width'))181 for pt in ellipse_dots(box, cycle, start, end):182 self.draw.line([pt, pt], fill=kwargs['fill'])183 else:184 self.draw.arc(box.to_integer_point(), start, end, **kwargs)185186 def ellipse(self, box, **kwargs):187 if 'filter' in kwargs:188 del kwargs['filter']189190 style = kwargs.get('style')191 if 'style' in kwargs:192 del kwargs['style']193194 if style:195 if kwargs.get('fill') != 'none':196 kwargs2 = dict(kwargs)197 if 'outline' in kwargs2:198 del kwargs2['outline']199 self.draw.ellipse(box, **kwargs2)200201 if 'outline' in kwargs:202 kwargs['fill'] = kwargs['outline']203 del kwargs['outline']204205 cycle = style2cycle(style, kwargs.get('width'))206 for pt in ellipse_dots(box, cycle):207 self.draw.line([pt, pt], fill=kwargs['fill'])208 else:209 if kwargs.get('fill') == 'none':210 del kwargs['fill']211212 self.draw.ellipse(box.to_integer_point(), **kwargs)213214 def line(self, xy, **kwargs):215 if 'jump' in kwargs:216 del kwargs['jump']217 if 'thick' in kwargs:218 if kwargs['thick'] is not None:219 kwargs['width'] = kwargs['thick']220 del kwargs['thick']221222 style = kwargs.get('style')223 if kwargs.get('fill') == 'none':224 pass225 elif (style in ('dotted', 'dashed', 'none') or226 re.search(r'^\d+(,\d+)*$', style or "")):227 self.dashed_line(xy, **kwargs)228 else:229 if 'style' in kwargs:230 del kwargs['style']231232 self.draw.line(xy, **kwargs)233234 def dashed_line(self, xy, **kwargs):235 style = kwargs.get('style')236 del kwargs['style']237238 cycle = style2cycle(style, kwargs.get('width'))239 for line in line_segments(xy):240 for subline in dashize_line(line, cycle):241 self.line(subline, **kwargs)242243 def rectangle(self, box, **kwargs):244 thick = kwargs.get('thick', self.scale_ratio)245 fill = kwargs.get('fill')246 outline = kwargs.get('outline')247 style = kwargs.get('style')248249 if thick == 1:250 d = 0251 else:252 d = int(math.ceil(thick / 2.0))253254 if fill and fill != 'none':255 self.draw.rectangle(box, fill=fill)256257 x1, y1, x2, y2 = box258 lines = (((x1, y1), (x2, y1)), ((x1, y2), (x2, y2)), # horizonal259 ((x1, y1 - d), (x1, y2 + d)), # vettical (left)260 ((x2, y1 - d), (x2, y2 + d))) # vertical (right)261262 for line in lines:263 self.line(line, fill=outline, width=thick, style=style)264265 def polygon(self, xy, **kwargs):266 if 'filter' in kwargs:267 del kwargs['filter']268269 if kwargs.get('fill') != 'none':270 kwargs2 = dict(kwargs)271272 if 'style' in kwargs2:273 del kwargs2['style']274 if 'outline' in kwargs2:275 del kwargs2['outline']276 self.draw.polygon(xy, **kwargs2)277278 if kwargs.get('outline'):279 kwargs['fill'] = kwargs['outline']280 del kwargs['outline']281 self.line(xy, **kwargs)282283284 def textfolder(self):285 textfolder = super(ImageDrawExBase, self).textfolder286 return partial(textfolder, scale=self.scale_ratio)287288289 def textlinesize(self, string, font):290 ttfont = ttfont_for(font)291 if ttfont is None:292 if hasattr(self.draw, 'textbbox'):293 left, top, right, bottom = self.draw.textbbox((0, 0), string)294 size = (right - left, bottom - top)295 else:296 size = self.draw.textsize(string, font=None)297298 font_ratio = font.size * 1.0 / FontMap.BASE_FONTSIZE299 size = Size(int(size[0] * font_ratio),300 int(size[1] * font_ratio))301 else:302 if hasattr(ttfont, 'getbbox'):303 left, top, right, bottom = ttfont.getbbox(string)304 size = Size(right - left, bottom - top)305 else:306 size = Size(*ttfont.getsize(string))307308 return size309310 def text(self, xy, string, font, **kwargs):311 fill = kwargs.get('fill')312 ttfont = ttfont_for(font)313314 if ttfont is None:315 if self.scale_ratio == 1 and font.size == FontMap.BASE_FONTSIZE:316 self.draw.text(xy, string, fill=fill)317 else:318 if hasattr(self.draw, 'textbbox'):319 left, top, right, bottom = self.draw.textbbox((0, 0), string)320 size = (right - left, bottom - top)321 else:322 size = self.draw.textsize(string)323 image = Image.new('RGBA', size)324 draw = ImageDraw.Draw(image)325 draw.text((0, 0), string, fill=fill)326 del draw327328 basesize = (size[0] * self.scale_ratio,329 size[1] * self.scale_ratio)330 text_image = image.resize(basesize, Image.Resampling.LANCZOS)331 self.paste(text_image, xy, text_image)332 else:333 if hasattr(ttfont, 'getbbox'):334 left, top, right, bottom = ttfont.getbbox(string)335 size = (right - left, bottom - top)336 else:337 size = ttfont.getsize(string)338339 # Generate mask to support BDF(bitmap font)340 mask = Image.new('1', size)341 draw = ImageDraw.Draw(mask)342 draw.text((0, 0), string, fill='white', font=ttfont)343344 # Rendering text345 filler = Image.new('RGB', size, fill)346 self.paste(filler, xy, mask)347348 def textarea(self, box, string, font, **kwargs):349 if 'rotate' in kwargs and kwargs['rotate'] != 0:350 angle = 360 - int(kwargs['rotate']) % 360351 del kwargs['rotate']352353 if angle in (90, 270):354 _box = Box(0, 0, box.height, box.width)355 else:356 _box = box357358 text = ImageDrawEx(None, parent=self, transparency=True)359 text.set_canvas_size(_box.size)360 textbox = Box(0, 0, _box.width, _box.height)361 text.textarea(textbox, string, font, **kwargs)362363 filler = Image.new('RGB', box.size, kwargs.get('fill'))364365 mask = text._image.rotate(angle, expand=True)366 if mask.size != filler.size:367 # Image.rotate(expand=True) of Pillow earlier than368 # 3.3.0 (including 2.x) returns image object with369 # unexpected size: for example, rotating 10x20 by 270370 # causes not 20x10 but 21x11.371 # Therefore, crop rotated image in order to make it372 # match against size of "filler".373 mask = mask.crop((0, 0, box.width, box.height))374375 self.paste(filler, box.topleft, mask)376 return377378 lines = self.textfolder(box, string, font, **kwargs)379380 if kwargs.get('outline'):381 outline = kwargs.get('outline')382 self.rectangle(lines.outlinebox, fill='white', outline=outline)383384 rendered = False385 for string, xy in lines.lines:386 self.text(xy, string, font, **kwargs)387 rendered = True388389 if not rendered and font.size > 0:390 _font = font.duplicate()391 _font.size = int(font.size * 0.8)392 self.textarea(box, string, _font, **kwargs)393394 def image(self, box, url):395 if not box.width or not box.height:396 # resizing image into "0 width and/or height" is meaningless397 return398399 try:400 image = images.open(url, mode='pillow')401402 # resize image.403 w = min([box.width, image.size[0] * self.scale_ratio])404 h = min([box.height, image.size[1] * self.scale_ratio])405 image.thumbnail((w, h), Image.Resampling.LANCZOS)406407 # centering image.408 w, h = image.size409 if box.width > w:410 x = box[0] + (box.width - w) // 2411 else:412 x = box[0]413414 if box.height > h:415 y = box[1] + (box.height - h) // 2416 else:417 y = box[1]418419 if image.mode == 'P':420 # convert P to RGBA to masking transparent pixels421 image = image.convert('RGBA')422423 if image.mode == 'RGBA':424 self.paste(image, (round(x), round(y)), mask=image)425 else:426 self.paste(image, (round(x), round(y)))427 except IOError:428 pass429430 def save(self, filename, size, _format):431 if filename:432 self.filename = filename433434 if size is None:435 x = int(self._image.size[0] / self.scale_ratio)436 y = int(self._image.size[1] / self.scale_ratio)437 size = (x, y)438439 self._image.thumbnail(size, Image.Resampling.LANCZOS)440441 if self.filename:442 self._image.save(self.filename, _format)443 image = None444 else:445 from io import BytesIO446 tmp = BytesIO()447 self._image.save(tmp, _format)448 image = tmp.getvalue()449450 return image451452453def blurred(fn):454 PADDING = 16455456 def get_shape_box(*args):457 if fn.__name__ == 'polygon':458 xlist = [pt.x for pt in args[0]]459 ylist = [pt.y for pt in args[0]]460 return Box(min(xlist), min(ylist), max(xlist), max(ylist))461 else:462 return args[0]463464 def get_abs_coordinate(box, *args):465 dx = box.x1 - PADDING466 dy = box.y1 - PADDING467 if fn.__name__ == 'polygon':468 return [pt.shift(-dx, -dy) for pt in args[0]]469 else:470 return box.shift(-dx, -dy)471472 def create_shadow(self, size, *args, **kwargs):473 drawer = ImageDrawExBase(self.filename, transparency=True)474 drawer.set_canvas_size(size)475 getattr(drawer, fn.__name__)(*args, **kwargs)476477 for _ in range(15):478 drawer._image = drawer._image.filter(ImageFilter.SMOOTH_MORE)479480 return drawer._image481482483 def func(self, *args, **kwargs):484 args = list(args)485486 if kwargs.get('filter') not in ('blur', 'transp-blur'):487 return fn(self, *args, **kwargs)488 else:489 box = get_shape_box(*args)490 args[0] = get_abs_coordinate(box, *args)491492 size = Size(box.width + PADDING * 2, box.height + PADDING * 2)493 shadow = create_shadow(self, size, *args, **kwargs)494 xy = XY(box.x1 - PADDING, box.y1 - PADDING)495 self.paste(shadow, xy, shadow)496497 return func498499500class ImageDrawEx(ImageDrawExBase):501502 def ellipse(self, box, **kwargs):503 super(ImageDrawEx, self).ellipse(box, **kwargs)504505506 def rectangle(self, box, **kwargs):507 super(ImageDrawEx, self).rectangle(box, **kwargs)508509510 def polygon(self, xy, **kwargs):511 super(ImageDrawEx, self).polygon(xy, **kwargs)512513514def setup(self):515 from blockdiag.imagedraw import install_imagedrawer516 install_imagedrawer('png', ImageDrawEx)