Kindle for PCのスクショを撮る(強化版)

# Kindle for PC コレクション対応記念
嘘です。
もうそろそろなんとかしたかっただけです
本来なら元記事の変更で済ますべきなんでしょうが、ソースコードがかなり変更されたので、新記事にしました。

https://qiita.com/dengax/items/d2f64d462715a640184b

の強化版です

# 変更点
* 設定ファイルを使うようになった
　カレントディレクトリのkindless.iniファイルを参照して動作設定をするようになりました。
　コマンドラインで設定ファイルを指定すると、その設定ファイルを読むようになりました。
　PyInstallerやNuitkaで実行ファイル化できるかもしれません
* tkinter以外のダイアログを表示するようにしました
　ソースコードを置き換えればWXPython , PyQt5 , PyQt6 , tkinterのうちの一つを選べます
* ウィンドウ誤爆対策
　実行ファイルがkindle.exeのものを選択するようにしました
* ウィンドウからタイトルを拾うようにした
　タイトルがウィンドウに表示されているので、それを初期値としてダイアログボックスに予め入力しておく事にしました(設定で切替可)
* 最初のページに自動で移動
　最初のページに自動的に移動するようにしました(設定で切替可)
* エラーチェックの強化
　スクショ中、Kindle for PCを閉じれば終了するようにしました。また、なるべくKindle for PCをフルスクリーンにしたまま終了しないようにしました
* ソースコード整理
　ちゃんとmain関数内に入れた
* 後処理の強化
　小説の場合、モノクロ判定をして、モノクロの場合はモノクロPNGで保存するようにしました
　コミックの場合でもモノクロ判定とトリミングを行うようにしました
* 一部の処理のスレッド化
　コミックが対象の場合、スクショ後にまとめて画像変換とトリミングをしますが、そのときに必要な判別処理はスクショ中に別スレッドで行うようにしたため、速度が安定しました

# ソースコード
ソースコードは4つに分かれています。

<details><summary>kindless.py</summary>

```python:kindless.py
from dataclass import KindleSSConfig, read_config
from wxdialog import SimpleDialog, Icon
from WindowInfo import *

import threading, queue
import sys, os, os.path as osp, datetime , time
import shutil
import dataclasses

import pyautogui as pag
import cv2, numpy as np
from PIL import ImageGrab

rep_list = [['　',' '],[':','：'],[';','；'],['（','('],['）',')'],['［','['],['］',']'],
            ['&','＆'],['"','”'],['|','｜'],['?','？'],['!','！'],['*','＊'],['\\','￥'],
            ['<','＜'],['>','＞'],['/','／']]

@dataclasses.dataclass
class Margin:
    top : int
    bottom : int
    left : int
    right : int

@dataclasses.dataclass
class ThreadArgs:
    endflag : bool
    page : int
    filename : str
    image : np.ndarray

@dataclasses.dataclass
class ThreadResult:
    margin_left : int
    margin_right : int
    gray : bool
    filename : str


class CaptureWrapper:
    def __init__(self):
        pass
    def capture(self) -> np.ndarray:
        cap = ImageGrab.grab()
        return cv2.cvtColor(np.array(cap), cv2.COLOR_RGB2BGR)


def imread(filename: str, flags=cv2.IMREAD_COLOR, dtype=np.uint8) -> np.ndarray:
    n = np.fromfile(filename, dtype)
    img = cv2.imdecode(n, flags)
    return img


def imwrite(filename: str, img : np.ndarray, params=None) -> bool:
    ext = os.path.splitext(filename)[1]
    result, n = cv2.imencode(ext, img, params)
    if result:
        with open(filename, mode='w+b') as f:
            n.tofile(f)
        return True
    else:
        return False


def trim_check(img: np.ndarray, color, margin: Margin):
    def cmps(img, xrange, yrange , color, xdef):
        rt = xdef
        for x in xrange:
            if (img[yrange[0]:yrange[1] , x] != color).any():
                rt = x
                break
        return rt
    
    sx, sy = img.shape[1], img.shape[0]
    nx, ny = margin.top, margin.bottom
    xx, xy = sx - margin.left, sy - margin.right
    lm = cmps(img, range(nx, xx), (ny, xy),color, sx)
    if lm == nx:
        lm = 0
    rm = cmps(img, reversed(range(nx, xx)), (ny, xy), color, 0)
    if rm == xx:
        rm = sx
    return lm,rm


def color_check(img: np.ndarray, mg:Margin) -> int:
    imx = img.shape[1]
    imy = img.shape[0]
    img_blue, img_green, img_red = cv2.split(img[mg.top : imx - mg.bottom , mg.left : imy - mg.right])
    img_bg = np.abs(img_blue.astype(int) - img_green.astype(int))
    img_gr = np.abs(img_green.astype(int) - img_red.astype(int))
    img_rb = np.abs(img_red.astype(int) - img_blue.astype(int))
    return max(img_bg.max(),img_gr.max(),img_rb.max()) 


def capture(cfg: KindleSSConfig, dir_title: str, page: int):
    print('Cap start')
    sc_w, sc_h = pag.size()

    cap = CaptureWrapper()
    imp = cap.capture()

    def cmps(img,rng):
        for i in rng:
            if np.all(img[20][i] != img[19][0]):
                return i

    lft = cmps(imp,range(cfg.left_margin, imp.shape[1] - cfg.right_margin))
    rht = cmps(imp,reversed(range(cfg.left_margin, imp.shape[1] - cfg.right_margin)))
    
    print(lft, sc_w - rht, imp[int(sc_h / 2) , lft +1])
    comic = True if lft < 60 and sc_w - rht < 60 and max(imp[int(sc_h / 2) , lft +1]) == 0 else False
    ext = '.png' if comic else cfg.file_extension

    old = np.zeros((sc_h , rht-lft, 3), np.uint8)

    if comic and cfg.trim_after_capture:
        arg = queue.Queue()
        rslt = queue.Queue()
        cv = threading.Condition()
        trim = Margin(cfg.trim_margin_top, cfg.trim_margin_bottom, cfg.trim_margin_left, cfg.trim_margin_right)
        gray = Margin(cfg.grayscale_margin_top, cfg.grayscale_margin_bottom, cfg.grayscale_margin_left, cfg.grayscale_margin_right)
        thr = threading.Thread(args=(cv, arg, trim, gray, rslt, cfg.grayscale_threshold),target=thread)
        thr.start()
  
    loop = True
    while loop:
        if GetWindowHandleWithName(cfg.window_title, cfg.execute_filename) == None:
            sys.exit()
        filename = osp.join(dir_title , str(page).zfill(3) + ext)
        start = time.perf_counter()
        while True:
            time.sleep(cfg.capture_wait)
            ss = cap.capture()
            ss = ss[:, lft: rht]

            if not np.array_equal(old, ss):
                break
            if time.perf_counter()- start > cfg.timeout_wait:
                pag.press(cfg.fullscreen_key)
                loop = False
                break
        if loop:
            old = ss.copy()
            if not comic and cfg.trim_after_capture:
                if color_check(ss, Margin(0,0,0,0)) <= cfg.grayscale_threshold:
                    imwrite(filename,ss[:,:,1])
                else:
                    imwrite(filename,ss)
            else:
                with cv:
                    arg.put(ThreadArgs( False, page, filename, ss ))
                    cv.notify()

            print('Page:', page, ' ', ss.shape, time.perf_counter() - start, 'sec')
            page += 1
            pag.keyDown(cfg.nextpage_key)

    if comic and cfg.trim_after_capture:
        with cv:
            arg.put(ThreadArgs(True, 0, '', None))
            cv.notify()
        thr.join()

        r : list[ThreadResult] = []
        while not rslt.empty():
            r += [rslt.get()]
        ml = min([x.margin_left for x in r])
        mr = max([x.margin_right for x in r])
        print('trim =', ml, mr)
        for i in r:
            print(i.filename, end='')
            s = imread(i.filename)
            s = s[:,ml:mr]
            if i.gray:
                s = cv2.cvtColor(s, cv2.COLOR_RGB2GRAY)
                print(' is grayscale', end = '')
            fn = osp.splitext(i.filename)[0] + cfg.file_extension
            os.remove(i.filename)
            imwrite(fn,s)
            print()
    return

def thread(cv: threading.Condition, que: queue.Queue, trm: Margin, gray: Margin, out: queue.Queue,gs : int):
    end_flag = False
    sc_w, sc_h = pag.size()
    ml = sc_w
    mr = 0
    while not end_flag:
        while not que.empty():
            arg : ThreadArgs = que.get()
            if arg.endflag:
                end_flag = True
                break
            tm = trim_check(arg.image, arg.image[1, 1],trm)
            ml = min(ml, tm[0])
            mr = max(mr, tm[1])
            gst = Margin(gray.top, gray.bottom, gray.left + ml, mr - gray.right)
            gr = (color_check(arg.image, gst) <= gs)
            imwrite(arg.filename, arg.image)
            rslt = ThreadResult(tm[0], tm[1], gr, arg.filename)
            out.put(rslt)
        else:
            with cv:
                cv.wait()


def main():
    if len(sys.argv) >= 2:
        ini = sys.argv[1]
    else:
        ini = 'kindless.ini'

    cfg : KindleSSConfig = read_config(KindleSSConfig(), ini)
    ghwnd = GetWindowHandleWithName(cfg.window_title, cfg.execute_filename)
    if ghwnd == None:
        SimpleDialog.infomation(title="エラー", label="Kindleが見つかりません", icon=Icon.Exclamation)
        sys.exit()

    t = GetWindowText(ghwnd)
    if (idx := t.find(' - ')) != -1:
        t = t[idx + 3 :]
        for i in rep_list:
            t = t.replace(i[0],i[1])
    else:
        t = str(datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S"))
    if not cfg.auto_title:
        t = ''
    SetForeWindow(ghwnd)
    time.sleep(cfg.short_wait)
    if cfg.force_move_first_page:
        pag.hotkey(*cfg.pagejump_key)
        time.sleep(cfg.short_wait)
        pag.press(cfg.pagejump)
        pag.press('enter')
        time.sleep(cfg.capture_wait)

    pag.press(cfg.fullscreen_key)
    time.sleep(cfg.long_wait)

    sc_w, sc_h = pag.size()
    pag.moveTo(sc_w / 2, sc_h / 2)
    ok, book_title = SimpleDialog.askstring(title="タイトル入力", label="タイトルを入れてね",value= t, width=400)
    if not ok:
        pag.press(cfg.fullscreen_key)
        time.sleep(cfg.long_wait)
        sys.exit()
    append = False
    if book_title[0] == '+':
        append = True
        book_title = book_title[1:]
    dir_title = osp.join(cfg.base_save_folder,book_title)
    print(dir_title)
    page = 1
    if osp.exists(dir_title):
        if append:
            page = max([int(os.path.splitext(os.path.basename(i))[0])
                    for i in os.listdir(dir_title)]) + 1 if append else 1
        elif cfg.overwrite:
            shutil.rmtree(dir_title)
            os.makedirs(dir_title)
        else:
            pag.press(cfg.fullscreen_key)
            time.sleep(cfg.long_wait)
            SimpleDialog.infomation(title="エラー", label="ディレクトリが存在します", icon=Icon.Exclamation)
            sys.exit()
    else:
        try:
            os.makedirs(dir_title)
        except OSError as e:
            pag.press(cfg.fullscreen_key)
            time.sleep(cfg.long_wait)
            SimpleDialog.infomation(title="エラー", label="ディレクトリが作成できませんでした", icon=Icon.Exclamation)
            sys.exit()
    time.sleep(cfg.fullscreen_wait)
    capture(cfg, dir_title, page)


if __name__ == "__main__":
    main()
```
</details>

<details><summary>WindowInfo.py</summary>

```Python:WindowInfo.py
from ctypes import (windll, WINFUNCTYPE, POINTER, c_bool, c_int, c_ulong, pointer, create_unicode_buffer)
from ctypes.wintypes import HWND, RECT
import os.path

EnumWindows = windll.user32.EnumWindows
WNDENUMPROC = WINFUNCTYPE(c_bool, POINTER(c_int), POINTER(c_int))

ignores = ['Default IME', 'MSCTFIME UI']

ghwnd = None
wintitle = ''
pName = None
windowlist = []

# uflags
SWP_ASYNCWINDOWPOS = 0x4000
SWP_DEFERERASE = 0x2000
SWP_DRAWFRAME = 0x0020
SWP_FRAMECHANGED = 0x0020
SWP_HIDEWINDOW = 0x0080
SWP_NOACTIVATE = 0x0010
SWP_NOCOPYBITS = 0x0100
SWP_NOMOVE = 0x0002
SWP_NOOWNERZORDER = 0x0200
SWP_NOREDRAW = 0x0008
SWP_NOREPOSITION = 0x0200
SWP_NOSENDCHANGING = 0x0400
SWP_NOSIZE = 0x0001
SWP_NOZORDER = 0x0004
SWP_SHOWWINDOW = 0x0040

def EnumWindowsProc(hwnd, lParam):
    global ghwnd, wintitle, pname
    length = windll.user32.GetWindowTextLengthW(hwnd)
    buff = create_unicode_buffer(length + 1)
    windll.user32.GetWindowTextW(hwnd, buff, length + 1)
    if buff.value.find(wintitle) != -1:
        if pname is not None:
            p = os.path.basename(GetWindowThreadProcessName(hwnd))
            if p.upper() != pname.upper():
                return True
        ghwnd = hwnd
        return False
    return True

def EnumWindowsListProc(hwnd, lParam):
    global windowlist, ignores
    length = windll.user32.GetWindowTextLengthW(hwnd)
    buff = create_unicode_buffer(length + 1)
    windll.user32.GetWindowTextW(hwnd, buff, length + 1)
    if not any([buff.value.find(x) != -1 for x in ignores]) and buff.value != '':
        windowlist.append({'Text': buff.value, 'HWND': hwnd, 'Pos': GetWindowRect(ghwnd), 'pid': GetWindowThreadProcessId(hwnd) , 'Location': GetWindowThreadProcessName(hwnd)})
    return True

def GetWindowThreadProcessId(hwnd):
    pid = c_ulong()
    windll.user32.GetWindowThreadProcessId(hwnd,pointer(pid))
    return pid.value

def GetWindowThreadProcessName(hwnd):
    pid = c_ulong()
    windll.user32.GetWindowThreadProcessId(hwnd, pointer(pid))
    handle = windll.kernel32.OpenProcess(0x0410, 0, pid)
    buffer_len = c_ulong(1024)
    buffer = create_unicode_buffer(buffer_len.value)
    windll.kernel32.QueryFullProcessImageNameW(handle, 0,pointer(buffer),pointer(buffer_len))
    buffer = buffer[:]
    buffer = buffer[:buffer.index("\0")]
    return str(buffer)

def GetWindowHandle(title):
    global ghwnd, wintitle, pname
    ghwnd = None
    wintitle = title
    pname = None
    EnumWindows(WNDENUMPROC(EnumWindowsProc), 0)
    return ghwnd

def GetWindowHandleWithName(title,name):
    global ghwnd, wintitle, pname
    ghwnd = None
    wintitle = title
    pname = name
    EnumWindows(WNDENUMPROC(EnumWindowsProc), 0)
    return ghwnd

def GetWindowList():
    global windowlist
    windowlist = []
    EnumWindows(WNDENUMPROC(EnumWindowsListProc), 0)
    return windowlist

def SetForeWindow(hwnd):
    windll.user32.SetForegroundWindow(hwnd)

def SetWindowPos(hwnd,x,y,cx,cy,uflags):
    hwndinsertafter = HWND()
    windll.user32.SetWindowPos(hwnd,hwndinsertafter,x,y,cx,cy,uflags)

def GetWindowText(hwnd):
    length = windll.user32.GetWindowTextLengthW(hwnd)
    buff = create_unicode_buffer(length + 1)
    windll.user32.GetWindowTextW(hwnd, buff, length + 1)
    return buff.value

def GetWindowRect(hwnd):
    rect = RECT()
    windll.user32.GetWindowRect(hwnd, pointer(rect))
    return rect.left,rect.top,rect.right,rect.bottom
```
</details>

<details><summary>dataclass.py</summary>

```Python:dataclass.py
from typing import TypeVar
import dataclasses
import configparser
import os.path as osp

homedir = osp.join(osp.expanduser('~'),'kindle_scan')

@dataclasses.dataclass
class KindleSSConfig:
    window_title : str = 'Kindle'
    execute_filename : str = 'KINDLE.EXE'
    nextpage_key : str = 'left'
    fullscreen_key : str = 'f11'
    pagejump_key : list = dataclasses.field(default_factory=lambda: ['ctrl','g'])

    pagejump : str = '1'

    short_wait : float = 0.1
    long_wait : float = 0.2
    capture_wait : float = 0.35
    timeout_wait : float = 5.0
    fullscreen_wait : float = 2.0

    left_margin : int = 0
    right_margin : int = 0

    base_save_folder : str = homedir
    overwrite : bool = True
    trim_after_capture : bool = False
    force_move_first_page : bool = True
    auto_title : bool = True

    file_extension : str = '.png'
    grayscale_threshold : int = 2

    grayscale_margin_top : int = 1
    grayscale_margin_bottom : int = 16
    grayscale_margin_left : int = 1
    grayscale_margin_right : int = 1

    trim_margin_top : int = 1
    trim_margin_bottom : int = 16
    trim_margin_left : int = 1
    trim_margin_right : int = 1

def key_combination(strk : str)->list:
    lst = strk.split('+')
    return [i.strip() for i in lst]

def file_extension(ext : str)->str:
    return ext if ext[0] == '.' else '.' + ext

special_function = { 'pagejump_key' : key_combination , 'file_extension' : file_extension }
default_ini_name = 'kindless.ini'
default_section = 'KINDLESS'

DataClass = TypeVar('DataClass', bound= KindleSSConfig)

#
#
def read_config(dc: DataClass, ini: str)-> DataClass:
    if not osp.exists(ini):
        raise FileNotFoundError('ini file not found')

    section_name = default_section

    config = configparser.ConfigParser()
    config.read(ini, encoding= 'utf-8')
        
    for k, attr in dc.__annotations__.items():
        if k in special_function.keys():
            try:
                setattr(dc, k, special_function[k]( config.get( section_name, k ))) 
            except configparser.NoOptionError as e:
                print('WARNING : {} NO OPTION'.format(k))
                pass
        else:
            try:
                if attr is int:
                    setattr(dc, k, config.getint( section_name, k))
                elif attr is float:
                    setattr(dc, k, config.getfloat( section_name, k))
                elif attr is bool:
                    setattr(dc, k, config.getboolean( section_name, k))
                elif attr is str:
                    setattr(dc, k, config.get( section_name, k))
            except configparser.NoOptionError as e:
                print('WARNING : {} NO OPTION'.format(k))
    return dc
```
</details>

<details><summary>wxdialog.py</summary>

```Python:wxdialog.py
import wx
from enum import Enum

app = wx.App()
class Icon(Enum):
    Information = wx.ICON_INFORMATION
    Question = wx.ICON_QUESTION
    Exclamation = wx.ICON_EXCLAMATION
    Warning = wx.ICON_EXCLAMATION
    Error = wx.ICON_ERROR

class SimpleDialog:
    @staticmethod
    def askstring(title="", label="",value= "" ,parent=None, width=0, height=0)-> tuple[bool, str]:
        dlg = wx.TextEntryDialog(None, label,caption = title,value = value)
        dlg.Size = (width, height)
        dlg.WindowStyle |= wx.STAY_ON_TOP
        r = dlg.ShowModal() == wx.ID_OK
        v = dlg.GetValue()
        dlg.Destroy()
        return r, v
    
    @staticmethod
    def ask(parent = None, title="", label="", icon=Icon.Information)-> bool:
        dlg = wx.MessageDialog(None, label, title, wx.OK | wx.CANCEL | icon.value)
        dlg.WindowStyle |= wx.STAY_ON_TOP
        r = dlg.ShowModal() == wx.ID_OK
        dlg.Destroy()
        return r

    @staticmethod
    def infomation(parent = None, title="", label="", icon=Icon.Information)-> bool:
        dlg = wx.MessageDialog(None, label, title, wx.OK | icon.value)
        dlg.WindowStyle |= wx.STAY_ON_TOP
        dlg.ShowModal()
        dlg.Destroy()
        return True
```
</details>

設定ファイル
<details><summary>kindless.ini</summary>

```ini:kindless.ini
[KINDLESS]
window_title = Kindle
execute_filename = kindle.exe
# kindle_window_title Kindleの実行ファイル名
# kindle_execute_filename Kindle for PCの実行ファイル名

nextpage_key = left
fullscreen_key = f11
pagejump_key = ctrl + g
pagejump = 1
# kindle_nextpage_key 次のページを表示するキー
# kindle_fullscreen_key フルスクリーンにするキー
# kindle_pagejump_key ページ移動をするキー
# kindle_pagejump 指定のページ

short_wait = 0.1
long_wait = 0.2
capture_wait = 0.35
timeout_wait = 5.0
fullscreen_wait = 3.0
# short_wait 基本の短いウェイト
# long_wait 基本の長いウェイト
# capture_wait 次ページキーを押してからキャプチャするまでのウェイト
# timeout_wait 次ページキーを押しても同じ画像だった時に再試行を試みる秒数(最終ページ判別)
# fullscreen_wait キャプチャ開始前のウエイト

left_margin = 1
right_margin = 1
#サイズ自動設定のときの左右側マージン
#(常駐ソフトなどで、左右に何かがあるときに使用)

base_save_folder = e:\kindle_ss\
# 保存する場所 タイトルの前に入れられる

overwrite = True
trim_after_capture = True
force_move_first_page = True
auto_title = True
# overwrite 既に同じフォルダがあったときに、上書きする
# trim_after_capture キャプチャが終了した後に、コミックならコミック用のトリミングをする
# force_move_first_page キャプチャ前に、自動的に1ページ目にジャンプする
# ウィンドウの情報からタイトルを自動で入力する

file_extension = png
# file_extension 保存する画像のフォーマット

grayscale_margin_top = 0
grayscale_margin_bottom = 0
grayscale_margin_left = 0
grayscale_margin_right = 0
# グレイスケールの判別をするときの範囲

trim_margin_top = 1
trim_margin_bottom = 16
trim_margin_left = 0
trim_margin_right = 0
# コミックのトリミングをするときの範囲

grayscale_threshold = 7
# grayscale_threshold グレイスケール変換をするときのRGBの差の許容値
# ※グレイスケールのものをカラーjpegで保存すると、RGB値に誤差が出る事があります。その許容値です
```
</details>

wxpythonを使っていますが、

https://qiita.com/dengax/items/5e73e20ec41fdf29d3e9

この記事のいずれか１つのファイルと置き換える事で、tkinter, PyQt5, PyQt6と変更出来ます

Windows機種依存ですが、Macでも動作する[PyWinCtl](https://pypi.org/project/PyWinCtl/)なるものがあるようですから、そのあたりを書き換えればMacでも動作するかもしれません

ソースコードが４つに分かれているのは、長くなるから以外の理由は無いので、１つにまとめても問題ありません。まとめたのは[gistにでも置いておきます](https://gist.github.com/dengax/d8d6deced5013c48ba42ae78009c9242)

あ、多分Python3.9以降で動作します。開発時環境は3.10.11と3.11.4です

# 著作権について

このスクリプトを使用して作成した画像ファイルは、私的複製として、個人的に使用する用途以外では使用できません。

:::note alert
重要な事なので何度でも書きます
:::

# 機能改善(08/26追加)
使ってみて、ちょっと気になったところを修正

自動入力されているタイトルにWindows禁止文字が含まれると終了してしまうので、あと、全角スペースをファイル名に入れるのが嫌いなので、予め変換して自動入力にするようにした。

極端に長い画像の書籍(500ページ以上？)だと、途中で止まってしまう事があります。毎回同じ所で止まるので仕様でしょう。

なので、追記モードを実装しました。

タイトル入力時にタイトルの頭に半角の`+`を入力すると、追記モードになって続きから始めます。現在表示しているところからなので、途中で止まったときは１ページ進めてから実行してください。

なお、修正はkindless.pyのみです。Gistに置いた1ファイルにまとめたものは修正していません

## おまけ
撮ったものは圧縮ファイルとかにまとめると思いますが、圧縮自体をこのスクリプトに含ませる予定はありません。下のようなもので対応してください。zip.exeがパスに通っている必要があります
```batch:forzip.bat
@echo off
for /d %%a in (*.*) do (
echo ------------------------------------------------------------------ %%a
cd "%%a"
zip -m -T -r -o -9 "%%a.zip" *
move "%%a.zip" ..
cd ..
rmdir "%%a"
)