銀月の符号

Python 使い見習いの日記・雑記

画像にカレンダー出力

この前使った calendar モジュールとさらに前(ブログには未記述)に使った PIL をあわせて使ったら、絵にカレンダーが描けそうだったのでやってみた。

壁紙などの画像に直接カレンダーを描くのは考えが古い気もしたけれど、パソコン覚えたてのころに世話になっていた、壁紙にカレンダーを書き込むアプリ(名前忘れた)を再現して懐かしむということで。

以下の手順を基に作成したスクリプト

(2009-11-11更新)ImageCalendar のインスタンスを作成して、これの draw_month メソッドを呼ぶと、画像にカレンダーを描くことができる。使用例は test 関数にある。

(2009-11-12更新)カレンダーの背景色、背景透過度を指定できるようにした。

コマンドラインからも何とか使用できるようにした。ただし、望む出力に近づけていこうとすると、結局たくさんあるオプションをほぼすべて指定することとなる。正直、使いづらい。

# coding: utf-8

import calendar

from PIL import Image, ImageDraw, ImageFont

class ImageCalendar(calendar.Calendar):
    u"""PIL の画像にカレンダーを描画する calendar.Calendar のサブクラス"""

    month = (
            u'January', u'February', u'March', u'April',
            u'May', u'June', u'July', u'August',
            u'September', u'October', u'November', u'December')
    weekday = (u'Mo', u'Tu', u'We', u'Th', u'Fr', u'Sa', u'Su')

    def _create_month_header_image(self, theyear, themonth, font):
        s = u'%s %d' % (self.month[themonth - 1], theyear)
        im_size = font.getsize(s)

        im = Image.new('1', im_size)
        draw = ImageDraw.Draw(im)

        draw.text((0, 0), s, font=font, fill=1)
        return im

    def _get_box_size(self, font):
        _width = []
        _height = []
        text = [unicode(i) for i in range(1, 32)]
        text.extend(self.weekday)

        for w, h in (font.getsize(t) for t in text):
            _width.append(w)
            _height.append(h)

        width = max(_width)
        height = max(_height)

        return width, height

    def _get_month_list(self, theyear, themonth):
        weekdays = [
                (self.weekday[weekday], weekday)
                for weekday in self.iterweekdays()]
        dates = [
                [date for date in week]
                for week in self.monthdays2calendar(theyear, themonth)]
        month = []
        month.append(weekdays)
        month.extend(dates)
        return month

    def _create_month_masks(self, theyear, themonth, font):
        box_size = self._get_box_size(font)
        month = self._get_month_list(theyear, themonth)
        month_masks = []
        for week in month:
            week_masks = []
            for date in week:
                day_mask = Image.new('1', box_size, color=0)
                if date[0]:
                    date_text = unicode(date[0])
                    size = font.getsize(date_text)
                    draw = ImageDraw.Draw(day_mask)
                    draw.text(
                            (
                                (box_size[0] - size[0]) // 2, 
                                (box_size[1] - size[1]) // 2),
                            date_text, font=font, fill=1)
                week_masks.append((date, day_mask))
            month_masks.append(week_masks)
        return month_masks

    def create_month_image(
            self, theyear, themonth, font,
            padding=(0, 0),
            color=(64, 64, 64), sat_color=None, sun_color=None,
            alpha=255, bgcolor=(0, 254, 0), bgalpha=0, holiday=()):
        u"""一月分のカレンダー画像を作成する
        
        戻り値は theyear 年 themonth 月のカレンダーを描いた、
        RGB 画像と alpah バンドである。

        引数は以下の通り。
        theyear と themonth は年と月。
        font は フォントインスタンス。
        padding は日付間のパディング。
        color は通常の日付および年月を描く際に使用する色。
        sat_color, sun_color は土曜、日祝日を描く際に使用する色。
        alpah は透明度。
        bgcolor, bgalpha はカレンダー部分の背景の色および透明度。
        holiday は休日として扱う日付のリスト。

        カレンダーをある画像 im の (0, 0) 位置に貼り付けるには
        次のようにする。
        im.paste(cal_rgb, (0, 0), mask=cal_alpha)

        なお、 RGB と alpha に分かれているのが不都合であれば
        次のように処理することで RGBA 画像とすることができる。
        cal_rgba = cal_rgb.convert('RGB')
        cal_rgba.putalpha(cal_alpha)
        """

        if sat_color is None:
            sat_color = color
        if sun_color is None:
            sun_color = sat_color

        # 曜日、日付のマスクイメージのリスト
        month_masks = self._create_month_masks(theyear, themonth, font)
        # 行数
        line_count = len(month_masks)

        # 曜日、日付部分のサイズ
        box_width, box_height = month_masks[0][0][1].size
        # 曜日、日付間のパディング
        padding_x, padding_y = padding

        # 月名のマスクイメージ
        mask_header = self._create_month_header_image(
                theyear, themonth, font)

        # カレンダーのサイズ
        im_width =  max(
                box_width * 7 + padding_x * 6,
                mask_header.size[0])
        im_height = (
                box_height * line_count + padding_y * (line_count - 1) +
                padding_y + mask_header.size[1])
        im_size = (im_width, im_height)

        # カレンダー画像およびカレンダーマスクの作成
        cal = Image.new('RGB', im_size, bgcolor)
        cal_mask = Image.new('L', im_size, bgalpha)

        # 月名の描画
        position = (0, 0)
        cal.paste(
                color,
                (
                    position[0] + (im_width - mask_header.size[0]) // 2,
                    position[1]),
                mask_header)
        cal_mask.paste(
                alpha,
                (
                    position[0] + (im_width - mask_header.size[0]) // 2,
                    position[1]),
                mask_header)

        # 曜日、日付の描画
        month_position = (
                position[0],
                position[1] + mask_header.size[1], + padding_y)
        for y, week_masks in enumerate(month_masks):
            for x, (date, day_mask) in enumerate(week_masks):
                day_color = color
                if date[1] == 5:
                    day_color = sat_color
                elif date[1] == 6:
                    day_color = sun_color
                if date[0] in holiday:
                    day_color = sun_color

                day_position = (
                        (
                            month_position[0] +
                            (box_width + padding_x) * x),
                        (
                            month_position[1] +
                            (box_height + padding_y) * y))
                cal.paste(
                        day_color,
                        day_position,
                        day_mask)
                cal_mask.paste(
                        alpha,
                        day_position,
                        day_mask)

        return cal, cal_mask

    def create_month_image_rgba(
            self, theyear, themonth, font,
            padding=(0, 0),
            color=(64, 64, 64), sat_color=None, sun_color=None,
            alpha=255, bgcolor=(0, 254, 0), bgalpha=0, holiday=()):
        u"""一月分のカレンダー画像を作成する
        
        戻り値は theyear 年 themonth 月のカレンダーを描いた、
        RGBA 画像である。

        引数は create_month_image メソッドと同じである。

        カレンダーをある画像 im の (0, 0) 位置に貼り付けるには
        次のようにする。
        im.paste(cal_rgba, (0, 0), mask=cal_rgba)
        """

        cal_im, cal_mask = self.create_month_image(
                theyear, themonth, font, padding=padding,
                color=color, sat_color=sat_color, sun_color=sun_color,
                alpha=alpha, bgcolor=bgcolor, bgalpha=bgalpha,
                holiday=holiday)
        cal_rgba = cal_rgb.convert('RGB')
        cal_rgba.putalpha(cal_alpha)

        return cal_rgba

    def draw_month(
            self, im, theyear, themonth, font,
            position=(0, 0),padding=(0, 0),
            color=(64, 64, 64), sat_color=None, sun_color=None,
            alpha=255, bgcolor=(0, 254, 0), bgalpha=0, holiday=()):
        u"""im に一月分のカレンダーを描きこむ

        引数は以下の通り。
        im は PIL の イメージインスタンス。
        theyear と themonth は年と月。
        font は フォントインスタンス。
        position はカレンダーを描き込む位置。
        padding は日付間のパディング。
        color は通常の日付および年月を描く際に使用する色。
        sat_color, sun_color は土曜、日祝日を描く際に使用する色。
        alpah は透明度。
        bgcolor, bgalpha はカレンダー部分の背景の色および透明度。
        holiday は休日として扱う日付のリスト。
        """

        cal_im, cal_mask = self.create_month_image(
                theyear, themonth, font, padding=padding,
                color=color, sat_color=sat_color, sun_color=sun_color,
                alpha=alpha, bgcolor=bgcolor, bgalpha=bgalpha,
                holiday=holiday)
        im.paste(cal_im, position, mask=cal_mask)

def test():
    year = 2009
    month = 11
    holiday = (3, 23)
    position = (40, 30)
    padding = (8, 4)
    color = (32, 32, 32)
    sat_color = (0, 0, 192)
    sun_color = (160, 0, 0)
    alpha = 224
    bgcolor = (192, 192, 192)
    bgalpha = 30
    font_name = u'meiryo.ttc'
    font_size = 24

    im = Image.new('RGB', (640, 480), color=(255, 255, 255))
    font = ImageFont.truetype(font_name, font_size)
    cal = ImageCalendar(6)

    cal.draw_month(
            im, year, month, font,
            position=position, padding=padding,
            color=color, sat_color=sat_color, sun_color=sun_color,
            alpha=alpha, bgcolor=bgcolor, bgalpha=bgalpha,
            holiday=holiday)

    im.show()

def main():
    import os
    import datetime
    import optparse

    usage = 'usage: %prog src dst'
    version = '%prog 1.0'
    parser = optparse.OptionParser(usage=usage, version=version)

    parser.add_option('-f', '--force',
            action='store_true', dest='force',
            help=u'出力ファイルが存在しても上書きします')
    parser.add_option('--preview',
            action='store_true', dest='preview',
            help=u'生成結果をプレビュー表示します')
    parser.add_option('--year',
            action='store', type='int', dest='year',
            help=u'年を指定します')
    parser.add_option('--month',
            action='store', type='int', dest='month',
            help=u'月を指定します')
    parser.add_option('--font-name',
            action='store', type='string', dest='font_name',
            help=u'フォント名 (default: %default)')
    parser.add_option('--font-size',
            action='store', type='int', dest='font_size',
            help=u'フォントサイズ (default: %default)')
    parser.add_option('--position',
            action='store', nargs=2, type='int', dest='position',
            help=u'描き込む位置 (default: %default)')
    parser.add_option('--padding',
            action='store', nargs=2, type='int', dest='padding',
            help=u'日にち間のパディング (default: %default)')
    parser.add_option('--color',
            action='store', nargs=3, type='int', dest='color',
            help=u'通常色 (default: %default)')
    parser.add_option('--sat-color',
            action='store', nargs=3, type='int', dest='sat_color',
            help=u'土曜色 (default: 通常色と同じ)')
    parser.add_option('--sun-color',
            action='store', nargs=3, type='int', dest='sun_color',
            help=u'日曜色 (default: 土曜色と同じ)')
    parser.add_option('--alpha',
            action='store', type='int', dest='alpha',
            help=u'透過度 (default: %default)')
    parser.add_option('--bgcolor',
            action='store', nargs=3, type='int', dest='bgcolor',
            help=u'背景色 (default: %default)')
    parser.add_option('--bgalpha',
            action='store', type='int', dest='bgalpha',
            help=u'背景透過度 (default: %default)')
    parser.add_option('--ho', '--holiday',
            action='append', type='int', dest='holiday',
            help=u'休日扱いする日を指定します(複数回指定可)')

    parser.set_defaults(
            force=False, preview=False, year=None, month=None,
            font_name='meiryo.ttc', font_size=24,
            position=(0, 0), padding=(0, 0),
            color=(32, 32, 32), sat_color=None, sun_color=None,
            alpha=255, bgcolor=(128, 128, 128), bgalpha=0, holiday=[],
            )

    options, args = parser.parse_args()

    len_args = len(args)
    if len_args < 2:
        parser.error(u'引数が足りません')
    elif len_args > 3:
        parser.error(u'引数が多すぎます')

    org_img_path = args[0]
    dst_img_path = args[1]

    if not os.path.isfile(org_img_path):
        parser.error(u'%s は存在しないファイルです' % org_img_path)

    if not options.force and os.path.isfile(dst_img_path):
        parser.error(u'%s は存在するファイルです' % dst_img_path)

    try:
        im = Image.open(org_img_path)
    except IOError, e:
        parser.error(e.args[0])

    try:
        font = ImageFont.truetype(options.font_name, options.font_size)
    except IOError, e:
        parser.error(u'フォントを得ることができません')

    date = datetime.date.today()
    theyear = date.year if options.year is None else options.year
    themonth = date.month if options.month is None else options.month

    cal = ImageCalendar(6)
    cal.draw_month(
            im, theyear, themonth, font,
            position=options.position, padding=options.padding,
            color=options.color, sat_color=options.sat_color,
            sun_color=options.sun_color, alpha=options.alpha,
            bgcolor=options.bgcolor, bgalpha=options.bgalpha,
            holiday=options.holiday)

    im.save(dst_img_path)

    if options.preview:
        im.show()

if __name__ == '__main__':
    main()

手順

まずは日付情報

まずは、描きたい月の 1 日が何曜日から始まって、何日まであるかなどの情報を得る。 datetime モジュールを使って調べてもいいけれど、 calendar モジュールにまかせれば簡単 。

まずは Calendar クラスのインスタンスをつくる。引数は週の始まりを何曜日にするか選ぶためにある。デフォルトは 0 になっている。 0 は月曜日を意味する。見慣れた日本のカレンダーは日曜スタートなので 6 にしておく。整数を直接書く代わりにcalendar.SUNDAY といったモジュール定数をつかうこともできる。

インスタンスができたら、 monthdayscalendar メソッドで 1 週間ずつのリストが得られる。 0 となっている部分は先月分、来月分。他の月の日付も 0 としてリストに含まれるため、週を表すリストの長さが 7 というのは保障されている。ちなみに週の数は可変。普通は 5 だけど、たとえば 2009年2月は 4 つ、 2009年5月は 6 つになる。曜日は日曜日が最初、土曜日が最後となる。

>>> from calendar import Calendar
>>> cal = Calendar(6)
>>> for week in cal.monthdayscalendar(2009, 11):
...   print week
...
[1, 2, 3, 4, 5, 6, 7]
[8, 9, 10, 11, 12, 13, 14]
[15, 16, 17, 18, 19, 20, 21]
[22, 23, 24, 25, 26, 27, 28]
[29, 30, 0, 0, 0, 0, 0]
>>> for week in cal.monthdayscalendar(2009, 12):
...   print week
...
[0, 0, 1, 2, 3, 4, 5]
[6, 7, 8, 9, 10, 11, 12]
[13, 14, 15, 16, 17, 18, 19]
[20, 21, 22, 23, 24, 25, 26]
[27, 28, 29, 30, 31, 0, 0]
>>> len(cal.monthdayscalendar(2009,2))
4
>>> len(cal.monthdayscalendar(2009,5))
6
曜日情報

iterweekdays メソッドで曜日を表す番号が得られる。

>>> list(cal.iterweekdays())
[6, 0, 1, 2, 3, 4, 5]

なにか文字列に直す時はこんな感じで。

>>> weekdays = (u'Mo', u'Tu', u'We', u'Th', u'Fr', u'Sa', u'Su')
>>> [weekdays[i] for i in cal.iterweekdays()]
[u'Su', u'Mo', u'Tu', u'We', u'Th', u'Fr', u'Sa']
カレンダー画像を用意

まずは PIL をインポートしてフォントを取得。 TrueType や OpenType のものは読めることを確認。たとえば IPA Pゴシック。

>>> from PIL import ImageFont
>>> font = ImageFont.truetype(u'ipagp.otf', 24)

これで文字を書いたとき、どのくらいの大きさになるかを確認するには getsize メソッドが使える。

>>> font.getsize(u'spam')
(96, 24)

カレンダーを書くのに最低限必要な要素は、 Su, や Mo のような曜日を意味する文字列と、 1, 2, 3, ... 31 の文字列。描画した際、どのくらいのスペースを要するかを、先ほどの getsize メソッドを使って確認する。

>>> weekdays = (u'Su', u'Mo', u'Tu', u'We', u'Th', u'Fr', u'Sa')
>>> days = tuple(map(unicode, range(1, 32)))
>>> width = []
>>> height = []
>>> import itertools
>>> for w, h in (font.getsize(t) for t in itertools.chain(weekdays, days)):
...   width.append(w)
...   height.append(h)
...

そして一番大きいもののサイズを確認する。

>>> width = max(width)
>>> height = max(height)
>>> width, height
(48, 24)

このサイズの矩形を並べることでカレンダーとする。各矩形間に詰める dot 数はとりあえず 8, 4 ドットとした。

>>> padding_x = 8
>>> padding_y = 4
>>>
>>> week
[22, 23, 24, 25, 26, 27, 28]
>>> padding_x = 8
>>> padding_y = 4
>>> weeks = len(cal.monthdayscalendar(2009, 11))
>>> weeks
5
calsize = (width * 7 + padding_x * 6, height * weeks + padding_y * (weeks - 1))
>>> calsize
(384, 136)

これで必要とするカレンダー画像のサイズが 384, 136 と決まったので、このサイズで画像を作成する。まだ色は決める段階になく、文字の形が得られれば十分なので 2 値画像で。

>>> from PIL import Image
>>> cal_im = Image.new('1', calsize)

この画像に文字列を書き込むため ImageDraw インスタンスを作成する。

>>> from PIL import ImageDraw
>>> draw = ImageDraw.Draw(cal_im)

カレンダーをがしがし書き込んでいく。 width, height より小さいものはセンタリングするため、不足分 / 2 となる px, py をさらに位置に加える(曜日部分は取り合えず省略)。

>>> month = cal.monthdayscalendar(2009, 11)
>>> for x, y in ((x, y) for x in range(7) for y in range(weeks)):
...   i = month[y][x]
...   t = unicode(i)
...   fx, fy = font.getsize(t)
...   px, py = (width - fx) // 2, (height - fy) // 2
...   if i:
...     draw.text(
...       (x * (width + padding_x) + px, y * (height + padding_y) + py),
...       t,
...       font=font,
...       fill=1)
...

いったんプレビュー。

>>> cal_im.show()

黒地に白文字でカレンダーが描けていれば OK 。

画像にカレンダー画像を描き込む

先ほど作ったカレンダー画像を、ある画像 spam.jpg の 700, 100 位置に色 (255, 255, 0)、透明度 192、で描いてみる。

透明度 192 とするため、文字列部分の画素値 192 の 8bit画像 cal_im_L を作る。

>>> cal_im_L = Image.new('L', cal_im.size)
>>> cal_im_L.paste(192, mask=cal_im)

spam.jpg を読み込み、 cal_im_L をマスクにして (255, 255, 0) 色をペースト。

>>> spam_im = Image.open(u'spam.jpg')
>>> spam_im.paste((255, 255, 0), (700, 100), mask=cal_im_L)

完成。プレビューと保存は show と save メソッドで。

>>> spam_im.show()
>>> spam_im.save(u'spam_new.png')