Pillowに幾つかの機能を追加してみた

# はじめに
<!-- 発端や概要を記載 -->
本記事は、ある学生実験において、PillowというOSSに自分が追加した機能について説明した記事です。
以下に、追加した機能を列挙していきます。何か一つでも「お、役に立ちそう」と思えるような機能があれば幸いです。

# 環境
- Python3.11.1
- Pillow==10.1.0
- venv

# 追加した機能
<!-- タイトルとアンカー名を編集 -->
1. [テキストを指定した範囲で中央揃えする](https://qiita.com/oshibusu/private/e014b8debdeda9f86585#%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E7%AF%84%E5%9B%B2%E3%81%A7%E4%B8%AD%E5%A4%AE%E6%8F%83%E3%81%88%E3%81%99%E3%82%8B)
1. [指定した幅に合わせてフォントサイズを変更](https://qiita.com/oshibusu/private/e014b8debdeda9f86585#%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E5%B9%85%E3%81%AB%E5%90%88%E3%82%8F%E3%81%9B%E3%81%A6%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88%E3%82%B5%E3%82%A4%E3%82%BA%E3%82%92%E5%A4%89%E6%9B%B4)
1. [指定した幅に合わせて改行文字を追加](https://qiita.com/oshibusu/private/e014b8debdeda9f86585#%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E5%B9%85%E3%81%AB%E5%90%88%E3%82%8F%E3%81%9B%E3%81%A6%E6%94%B9%E8%A1%8C%E6%96%87%E5%AD%97%E3%82%92%E8%BF%BD%E5%8A%A0)
1. [画像を指定した範囲の中央に貼る](https://qiita.com/oshibusu/private/e014b8debdeda9f86585#%E7%94%BB%E5%83%8F%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E7%AF%84%E5%9B%B2%E3%81%AE%E4%B8%AD%E5%A4%AE%E3%81%AB%E8%B2%BC%E3%82%8B)

以下では、ImageDraw.pyにImageDrawクラスのメソッドとして追加したものは、`ImageDraw."メソッド名"`と表記し、Image.pyにImageクラスのメソッドとして追加したものは、`Image."メソッド名"`として表記しています。

## テキストを指定した範囲で中央揃えする
テキストを指定した範囲で中央揃えできる`ImageDraw.locate_text_center(text,box,fill,font)`というメソッドをImageDrawクラス内に追加しました。

- `text`:描画したい文章
- `box`:中央揃えをする範囲の座標(デフォルトはNone。2要素のタプルであれば左上の座標を指定し、右下はImageオブジェクトの右下隅とする。4要素のタプルであれば、範囲の左上と右下の座標を指定。)
- `fill`:テキストの色
- `font`:テキストのフォント（パスで指定する）とサイズ
- その他、textメソッドに元から用意されている引数も含んでいるが、かなり細かい設定なので、省いている。


普通のtext関数では、alignという引数により中央揃えは可能だったが、テキストを描画する範囲の左上のピクセル座標を指定できるのみであり、垂直方向の中央揃えをする機能がなかったため追加しました。
この関数はImageDraw.pyの中のImageDrawというclass内のメソッドとして、以下のコードにより実装されています。


``` Python: ImageDraw.py
def locate_text_center( 
    self, 
    text, 
    box=None,  
    fill=None,
    font=None,
    anchor=None,
    spacing=4,
    align="left", 
    direction=None,
    features=None,
    language=None,
    stroke_width=0,
    stroke_fill=None,
    embedded_color=False,
    position = "top", 
    *args,
    **kwargs,
): 
    """
    Draws text at the center of the area specified by box.
    The box is a  4-tuple defining the left, upper, right, and lower pixel
    coordinate, or 2-tuple defining the left and upper pixcel coordinate.
    If given 2-tuple, the right and lower pixcel coordinate are defined by image object size. 
    See :ref:`coordinate-system`.

    :param box: A rectangle, as a (left, upper, right, lower)-tuple or (left,upper)-tuple. Within this box, 
    text is located center.

    """ 
    im_width, im_height = self.im.size
    
    if box is None:
        box = (0, 0, im_width, im_height)

    if len(box) not in (2,4):
        raise ValueError("box must be a 2 or 4-tuple or None")
    elif len(box) == 2: 
        self._draw_text_center(box[0],box[1],im_width-box[0],im_height-box[1],text,fill,font)
    elif len(box) == 4: 
        self._draw_text_center(box[0],box[1],box[2]-box[0],box[3]-box[1],text,fill,font)    

def _draw_text_center(self, left, top, width, height, text, fill, font): 
    if width == None:
        width = self[1][0]
    if height == None:
        height = self[1][1]
    textbbox = self.textbbox((0,0), text, font)
    self.multiline_text((left + width/2 - textbbox[2]/2, top + height/2 - textbbox[3]/2), text, fill=fill, font=font)
```
この関数は、`_draw_text_center`を利用して、`box`により与えられた座標から、どこにテキストを描画すれば中央揃えになるかを計算して描画しています。


### 実行例

``` Python: locate_text_center_example.py
from PIL import Image, ImageFont, ImageDraw
img = Image.new("RGB", (100,100), "#2b26ad")
draw = ImageDraw.Draw(img)

font = ImageFont.truetype('/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc',10)
text = "Hello,world!"

draw.locate_text_center(text, font)　
img.show()
```

<img width="200" alt="default.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583589/bf3dc857-1ee8-77cb-969e-3842d973079a.jpeg">

## 指定した幅に合わせてフォントサイズを変更
指定した幅にピッタリ収まるようにフォントサイズを変更できる`ImageDraw.fit_text_width(text,font_path,position,width,fill=None,align="left")`というメソッドと、その中で動いている`ImageDraw.arrange_fontsize(text,font_path,width)`というメソッドを追加しました。

- `text`:描画したい文章
- `font_path`:文章のフォントのパス
- `position`:描画する文章の左上の位置
- `width`:文章をピッタリ収めたい幅
- `fill`:テキストの色を指定。デフォルトは`None`
- `align`:ピッタリ収めるため、ほぼ関係はないが、左揃えするか中央揃えするか右揃えするか。デフォルトは`left`
- その他、textメソッドに元から用意されている引数も含んでいるが、かなり細かい設定なので、省いている。

この関数は、textメソッドは、そのまま利用すると、画像の幅をはみ出ても改行される訳ではなく、そのまま見切れてしまうため、それを防ぐためにこのメソッドを追加しました。

```Python:ImageDraw.py
def arrange_fontsize(
    self, text, font_path, width, 
    anchor=None,
    spacing=4,
    align="left",
    direction=None,
    features=None,
    language=None,
    stroke_width=0,
    embedded_color=False,
):
    """
    Calculate the font size required to fit the given text within the specified maximum width.
    
    :param text: The text to be resized.
    :param font_path: The file path of the font to be used.
    :param width: The maximum width to fit the text into.
    :return: The adjusted font size to fit the text within the specified width.
    """
    
    font_size = 12
    font = ImageFont.truetype(font_path, font_size)
    box = self.textbbox(
        (0,0), text, font, anchor, spacing, align, direction, features, 
        language, stroke_width, embedded_color,
    )
    text_width = box[2] - box[0]
    ratio = width /  text_width
    font_size = int(font_size * ratio)
    return font_size

def fit_text_width(
    self, 
    text, 
    font_path, 
    position, 
    width,
    fill=None,
    anchor=None,
    spacing=4,
    align="left",
    direction=None,
    features=None,
    language=None,
    stroke_width=0,
    stroke_fill=None,
    embedded_color=False,
    *args,
    **kwargs,
):  
    """
    Draw the text to fit within the specified maximum width by automatically adjusting the font size.
    
    :param text: The text to be drawn.
    :param font_path: The file path of the font to be used.
    :param position: The position where the text will be drawn.
    :param width: The maximum width for the text.
    :param fill: The fill color for the text (default is None).
    :param align: The text alignment (default is "left").
    """
    
    font_size = self.arrange_fontsize(text, font_path, width)
    font = ImageFont.truetype(font_path, font_size)
    self.text(
        position, text, fill, font, anchor, spacing, 
        align, direction, features, language, stroke_width , 
        stroke_fill, embedded_color, *args, **kwargs,
    )
```


`ImageDraw.fit_text_width(text,font_path,position,width,fill)`は、フォントサイズは指定せずにフォントのパスのみを指定し、メソッドの中で`ImageDraw.arrange_fontsize`を呼び出して、幅にピッタリ収まる様にフォントサイズを計算して、`font_path`と合わせて描画しています。

### 実行例
``` python:fit_text_width_example.py
from PIL import Image, ImageFont, ImageDraw
img = Image.new("RGB", (100,100), "#2b26ad")
draw = ImageDraw.Draw(img)

font_path = '/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc'
text = "Hello,world!"

draw.fit_text_width(text, font_path, (0,40), 100)
img.show()

```

<img width="200" alt="default.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583589/7a220f0d-c78f-33da-4efa-cd293e42334c.jpeg">


## 指定した幅に合わせて改行文字を追加
指定した幅に収まるようにフォントサイズを変更できる`ImageDraw.create_multiline_text(text,width,font)`というメソッドを作りました。

- `text`:描画したい文章
- `width`:文章を収めたい幅。この幅に収まるように改行文字が追加される
- `font`:文章のフォント
- その他、textメソッドに元から含まれている変数も含んでいます


```python:ImageDraw.py
def create_multiline_text(
    self, text, width, font,
    anchor=None,
    spacing=4,
    align="left",
    direction=None,
    features=None,
    language=None,
    stroke_width=0,
    embedded_color=False,
):
    """
    Inserts "\n" in the given text to fit it 
    within the specified maximum width.
    """ 
    texts = '' 
    return self._create_multiline_text(
        text, font, width, texts, anchor, spacing, align, direction, features, 
        language, stroke_width, embedded_color,
    )

def _create_multiline_text(
    self, text, font, width, texts,
    anchor=None,
    spacing=4,
    align="left",
    direction=None,
    features=None,
    language=None,
    stroke_width=0,
    embedded_color=False,
):
    if(len(text) <= 0):
        return texts
    for i in range(len(text)):
        if text[i] == "\n":
            texts += text[:i+1]
            return self._create_multiline_text(
                text[i+1:], font, width, texts, anchor, spacing, align, direction, features, 
                language, stroke_width, embedded_color,
            )
        if self.textbbox(
                (0,0), text[:i+1], font, anchor, spacing, align, direction, features, 
                language, stroke_width, embedded_color,
            )[2] > width:
            texts += text[:i] + "\n"
            return self._create_multiline_text(
                text[i:], font, width, texts, anchor, spacing, align, direction, features, 
                language, stroke_width, embedded_color,
            )
    return texts + text
```
このメソッドは、textメソッドでは、テキストが画像の幅を超えても、見切れたまま表示されてしまい、それを防ぐためのメソッドが欲しいという理由から追加しました。このメソッドは、元々改行文字が入っている関数にも対応しています。

実装方法としては、フォントのサイズを考慮したtextを一文字ずつtextsという変数に入れていき、その幅がwidthを超えたところで改行文字を追加する、という動作を再帰的に書くことで実装しています。

### 実行例
``` python:create_multiline_text_example.py
from PIL import Image, ImageFont, ImageDraw
img = Image.new("RGB", (100,100), "#2b26ad")
img2 = img.copy()
draw = ImageDraw.Draw(img)
draw2 = ImageDraw.Draw(img2)

font = ImageFont.truetype('/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc',10)
text = "Daikibo-Zikkenn-Pillow"
text2 = "Daiki\nbo-Zikkenn-Pillow"

multilinetext = draw.create_multiline_text(text, 100, font)
draw.locate_text_center(multilinetext, font)
img.show()

multilinetext2 = draw2.create_multiline_text(text2, 100, font)
draw2.locate_text_center(multilinetext2, font)
img2.show()
```

<img width="200" alt="default.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583589/e0f7a9cb-2414-7e05-7c30-eb48e359966b.jpeg">

<img width="200" alt="default.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583589/2270cabf-acea-b2b1-55e1-0e8f30cbc22e.jpeg">

## 画像を指定した範囲の中央に貼る
先述の`ImageDraw.locate_text_center(text,box,fill,font)`と同様に、画像を指定した範囲の中央に貼る`Image.paste_center(image,box,alpha_composite,mask)`というメソッドを追加しました。

- `image`:下地の画像の上に貼りたい画像
- `box`:画像を中央に貼りたい範囲。デフォルトはNone。2要素のタプルであれば下地の左上の座標を指定し、右下は下地の画像の右下隅とする。4要素のタプルであれば、左上と右下の座標を指定。
- `alpha_composite`:デフォルトは`None`だが、`True`にすると透明度(alpha)を考慮する
- `mask`:`Image.paste`というメソッドに用意されている変数で、透明度等を指定するが、alpha_compositeの方が簡易的

```python:Image.py
def paste_center(self, image, box = None, alpha_composite = False, mask=None):
    """
    Locate image at the center of the area specified by box.
    The box is a  4-tuple defining the left, upper, right, and lower pixel
    coordinate, or 2-tuple defining the left and upper pixcel coordinate.
    If given 2-tuple, the right and lower pixcel coordinate are defined by image object size. 
    See :ref:`coordinate-system`.

    :param box: A rectangle, as a (left, upper, right, lower)-tuple or (left,upper)-tuple. Within this box, 
    text is located center.
    :param alpha_composite: Optional expansion flag.  If it is true and image mode is "RGBA",
    use <Image.alpha_composite>. If false or omitted, use <Image.paste>.
    """ 
    
    im_width, im_height = self.im.size
    
    if box is None:
        box = (0, 0, im_width, im_height)

    if len(box) not in (2,4):
        raise ValueError("box must be a 2 or 4-tuple or None")
    elif len(box) == 2: 
        box = (box[0],box[1],im_width,im_height)
    
    width, height = image.size

    box_width = box[2]-box[0]
    box_height = box[3]-box[1]
    
    x_center = box[0] + box_width // 2
    y_center = box[1] + box_height // 2

    x_offset = x_center - (width // 2)
    y_offset = y_center - (height // 2)

    if alpha_composite and image.mode == "RGBA":
        self.alpha_composite(image, dest = (x_offset, y_offset))
    else:
        self.paste(image, (x_offset, y_offset), mask)
```

このメソッドは、画像を貼るメソッドImage.paste()が、画像の左上の座標を指定するのみで、指定した範囲で中央揃えする機能が無く、その機能の必要性を感じたため追加しました。
実装方法としては、boxにより指定された範囲の中央に画像を貼るために、`x_offset`と`y_offset`を計算し、それを呼び出したImage.pasteに渡すことで実装しています。
また、`alpha_composite`がTrueであれば、`alpha_composite(image, dest = (x_offset, y_offset))`を呼び出すことで、透明度も考慮して貼り付けしています。
以下に、`alpha = false`の場合と、`alpha = True`の場合の二つを実行しています。

### 実行例
``` python:example.py
img = Image.new("RGB", (100,100), "#2b26ad")
test_image_rgba = Image.new("RGBA", (40,40), (255,0,0,80))
test_image_sitazi = Image.new("RGBA", (100,100), "#2b26ad")

img.paste_center(test_image_rgba)
test_image_sitazi.paste_center(test_image_rgba, (0,0,70,70),True)

img.show()
test_image_sitazi.show()
```

<img width="200" alt="default.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583589/fd7e2b21-502c-e7f1-2625-9c3bd08d37ad.jpeg">
<img width="200" alt="default.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583589/945ff5d1-3be1-27b6-e3f9-9a5bdc4afacd.png">

## 終わりに
pillowは、比較的シンプルなソフトウェアではありますが、意外とできることが多く、どのメソッドでどこまでの機能がついているのかを把握するのが少し難しかったです。
ただ、もっと高度なこともできる気がするので、皆さん是非挑戦してみてください。