Welcome to telecotele.com » 丁寧な暮らし

レシートプリンタからのフォント抽出

レシートプリンタの内蔵フォントを抽出する

tags: 2420txt store escpos

レシートプリンタからのフォント抽出のカバー画像

以前(2017年ごろ)、レシートプリンタに内蔵されている字形データを取得したことがあったのでまとめる。

方法の検討

字形データの取得方法としてまず思いつくのは、分解して漢字ROMから字形データを取り出すことでしょう。 ただ、これだとプリンタ機種ごとにどこが漢字ROMか?1読み出し方法は?などを調査しないとなりません。 手間がかかりますね。

ところで、レシートプリンタはたいていESC/POS2に対応しています。 では任意の文字をレシート用紙に印字して、それをスキャンして字形データを求めれば良いのでは? 印字とスキャンの手間はかかるものの、この方法だとESC/POSに対応しているプリンタすべてに対応できます。 これでいきましょう。

印字

印字します。 そのまま印字するだけだと文字が小さいため正確な字形の取得が難しく、また字形と文字コードの対応付けも困難です。

そこで、「文字は拡大して印字」「文字コード(Shift_JIS形式)の図形も印字」、さらに「±45°までの回転とサイズ補正のためにマーカーも印字」としました。

印字用のPythonスクリプトとしてはこんな感じに。

長いので折りたたみ
from escpos import *
from PIL import Image, ImageDraw
import json

# 外字登録
def createUsrDefChr(ptr, bmp):
	cmd = b'\x1c\x32\x77' + (0x21 + ptr).to_bytes(1, 'big')
	bmp = bmp.resize((24, 24))
	for x in range(24):
		u24 = 0
		for y in range(24):
			bit = (bmp.getpixel((x, y)) + 1) % 2
			u24 |= (bit << (23 - y))
		cmd += u24.to_bytes(3, 'big')
	return cmd

# 外字印字
def printUsrDefChr(ptr):
	return b'\x1c\x26\x77' + (0x21 + ptr).to_bytes(1, 'big') + b'\x1c\x2e'

# 印字コマンド
def printChr(c):
	try:
		cmd = c.encode('shift_jis')
		b = format(int.from_bytes(cmd, 'big'), 'b').zfill(16)
		if len(cmd) == 2:
			jis = c.encode('iso-2022-jp')
			cmd = b'\x1c\x26' + jis[3:5] + b'\x1c\x2e'
		return [ b, cmd ]
	except UnicodeEncodeError:
		return None

if __name__ == '__main__':
	MARKER_PTR = 0
	CODE_PTR = 1

	# 日本語設定
	cmd = b''
	cmd += b'\x1b\x52\x08'
	cmd += b'\x1b\x74\x01'
	cmd += b'\x1c\x43\x00'

	# マーカー登録
	marker = Image.new('1', (6, 6))
	ImageDraw.Draw(marker).rectangle([(1, 1), (4, 4)], outline=1)
	cmd += createUsrDefChr(MARKER_PTR, marker)

	# 印字
	cjk = json.load(open('cjk.json', 'r'))
	for (idx, c) in enumerate(cjk['jp']):
		print(c)
		tmp = printChr(c)
		if tmp is None: continue

		cmd += b'\x1d\x21\x22' + printUsrDefChr(MARKER_PTR)
		cmd += b'\x1d\x21\x00' + b'\x20'

		code = Image.new('1', (4, 4))
		for x in range(4):
			for y in range(4):
				code.putpixel((x, y), int(tmp[0][int(y * 4 + x)]))
		cmd += createUsrDefChr(CODE_PTR, code)
		cmd += b'\x1d\x21\x22' + printUsrDefChr(CODE_PTR)
		cmd += b'\x1d\x21\x00' + b'\x20'
		cmd += b'\x1d\x21\x55' + tmp[1] + b'\x0a'

		if (idx + 1) % 15 == 0:
			cmd += b'\x0a\x0a\x0a'

	p = printer.Usb(0x08a6, 0x0041)
	p._raw(cmd)

ここで使うプリンタは「B-EP2DL」を前提としています。 USB接続、(少なくともESC/POSではShift_JISは使えず)ISO-2022-JPで印字するやつです。

cjk.jsonは後述しますが、zi2zi/charset/cjk.json (kaonashi-tyc/zi2zi)と同一。 cjk['jp']には1408文字が含まれています。 お好みで文字を絞ったり、もちろん全文字を印字しても良いでしょう。

このスクリプトを実行すると、こんな感じで印字されます。

印字結果 印字結果

スキャン

スキャンして字形データを取得します。

まず、印字した紙をフラットヘッドスキャナでスキャンしておきます。 これはそれなりの手間がかかります……せめて長尺原稿に対応したスキャナだったらマシだったかも知れません。 それも面倒となれば、最初に戻ってやっぱり漢字ROMからの字形取得を試すとか。

スキャン画像をこんな感じのスクリプトで処理して、字形データを取得します。

これも折りたたみ
import cv2
import glob
import numpy as np

def resampling(img, size, r=0.3):
    dot = img.shape[0] / float(size)
    dst = np.zeros((size, size), dtype=np.uint8)
    for y in range(size):
        for x in range(size):
            mean = cv2.mean(img[int((y+r)*dot):int((y+1-r)*dot), int((x+r)*dot):int((x+1-r)*dot)])[0]
            if mean > 127: dst[y, x] = 255
    return dst

def img2encoding(filename, outdir):
    img = cv2.cvtColor(cv2.imread(filename), cv2.COLOR_BGR2GRAY)
    th = cv2.GaussianBlur(img, (17, 17), 0)
    _, th = cv2.threshold(th, 0, 255, cv2.THRESH_BINARY_INV|cv2.THRESH_OTSU)
    _, cnts, hie = cv2.findContours(th, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    for (idx, cnt) in enumerate(cnts):
        c = 0        
        k = idx
        while hie[0][k][2] != -1:
            c += 1
            k = hie[0][k][2]
        if c < 2:
            continue
        
        rect = cv2.minAreaRect(cnt)
        w, h = rect[1]
        if not (0.95 < w/h < 1.05):
            continue
        
        mom = cv2.moments(cnt)
        if not(0.95 < (mom['m00'] / (w * h)) < 1):
            continue
        
        box = cv2.boxPoints(rect)        
        cx = mom['m10'] / mom['m00']
        cy = mom['m01'] / mom['m00']
        lt = filter(lambda p:p[0] < cx and p[1] < cy, box)[0]
        rt = filter(lambda p:p[0] > cx and p[1] < cy, box)[0]
        rb = filter(lambda p:p[0] > cx and p[1] > cy, box)[0]

        src = np.float32([lt, rt, rb])
        dst = np.float32([[0, w], [w, w], [w, w*2]])
        M = cv2.getAffineTransform(src, dst)
        tmp = cv2.warpAffine(img, M, (int(w*5), int(w*3)))
        
        pattern = resampling(tmp[int(w):int(w*2), int(w + w/6.0):int(w*2 + w/6.0)], 4)
        sjis = sum([ (pattern[n//4, n%4] % 2) << (15 - n) for n in range(16) ])
        shape = resampling(tmp[:int(w*2), int(w*2 + w/3.0):int(w*4 + w/3.0)], 24)
        cv2.imwrite('{}/sjis-{:x}.png'.format(outdir, sjis), shape)

if __name__ == '__main__':
    for filename in glob.glob('src/*_0001.jpg'):
        img2encoding(filename, 'dst')

これで字形データが24x24の2値画像として保存されます。 一応デバッグ用として、元画像に読み出した字形(ドット)を重ねた画像を作ってみるとこんな感じに。

「ゆ」の読み取り結果 「ゆ」の読み取り結果

回転についてはうまく補正されているようですが、サイズの補正が危うい。 このケースだと横方向の拡大が足りないようですが、すべてのケースにおいてそうとも限らない。 横方向を拡大しすぎ、縦方向が怪しい場合もあるなどままなりません。 実際、他の字だと数ドット誤った字形を取得してしまう場合もありました……。

マーカーを複数配置する、たとえば文字の両端に配置しておいたり、QRコードのように囲んだりすると良かったかも知れません。 また、レシート用紙の幅は既知なので、スキャンする際に背景として黒い紙を挟んでおいてレシート用紙との境目をもとに補正するとか。 あとは単純に文字をもっと拡大して読み取りやすくしたり。

おわり

なにはともあれ、一応字形は取得できましたのでこれで良いです。 多少ミスすることもありますが、想定している用途だとあまり気になりません。 本編おわり。

おまけ: 動機

本来は最初のほうで書くことな気がしますが、これを実施した動機は「レシートプリンタで機種依存文字を印字したかったから」です。

内蔵フォントに機種依存文字は含まれていません。 機種依存文字が含まれている外部のフォント、たとえばNotoフォントなどを画像化して印字することはできますが、それだと内蔵フォントと組み合わせた際に違和感があります。 できれば内蔵フォントの雰囲気を残したまま機種依存文字を出力したい。 そこで、kaonashi-tyc/zi2ziを用いて内蔵フォントっぽい機種依存文字の画像を作成して印字するのを試していました。 その中のいち作業として、内蔵フォントの字形をデータセットとして取得する必要があったわけです。

機種依存文字の印字としてはそこそこうまくできたと思います。 画像など詳細は寄稿したもの3があるのでそちらを見てもらうとして……ですが、いま見返すと恥ずかしくて薄目でしか見れません。 若さを感じる。 まぁ成長を感じれて良いですねとしておきましょう。 なお、寄稿した原稿や画像は本記事では使っていません。念のため。

Footnotes

  1. 本当に漢字ROMとして独立したチップがあればマシですが、たとえばプリンタのMCUに内蔵されたEEPROMのいち領域として存在していたりするとより面倒そうです。

  2. ESC/POSによるデバイスの操作

  3. https://tatsu-zine.com/books/techbookfest-magazine-vol2 「髙﨑を印字だッ! 鷗(オウ)ッ!」