PyObjC Core Graphics

macOS 版 comipoli も PDF 対応にしたい。

NSPDFImageRep – AppKit | Apple Developer Documentation

こんなものがあったのか!
しかも NSImage からの継承。
ということは NSImage のメソッドもそのまま使えるってことだ。

elif is_pdf and re.search(r'\.pdf$', path, re.I):
    self.status = 3
    self.namelist.clear()
    data = NSData.dataWithContentsOfFile_(path)
    self.pdf = NSPDFImageRep.alloc().initWithData_(data)

######

elif self.status == 3:
    self.pdf.setCurrentPage_(num)
    return self.pdf

を前々回のソースに追記するだけで本当にイケた!
ただ cairo と Poppler の時と同じように背景は白で塗りつぶす必要があった。

しかし、画像だけの PDF だとトンデモなく表示が重いんですけど。。。。。
テキストだけの PDF なら余裕なんだけど。
筆者の mac は最新機とはいえ Air だし、pro なら。。。。。

もしかして Core Graphics を直で使ったら軽くなるかな?
と思ったので試すことに。
PyObjC の CoreGraphics は以下に入っていた
/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/Quartz

PDF Document Creation, Viewing, and Transforming

上記を PyObjC で簡単に使ってみる。
PATH は自前で持っている PDF に書き換えてください。

#!/usr/bin/env python3

import objc
from AppKit import *
from Quartz.CoreGraphics import *

#PATH = '/Users/sasakima-nao/Documents/ProgrammingWithObjectiveC.pdf'
PATH = '/Users/sasakima-nao/Documents/big_picture.pdf'

class ComipoliView(NSView):
    def init(self):
        objc.super(ComipoliView, self).init()
        # PDF Document
        url = NSURL.fileURLWithPath_isDirectory_(PATH, False)
        self.pdfdoc = CGPDFDocumentCreateWithURL(url)
        self.max_page = CGPDFDocumentGetNumberOfPages(self.pdfdoc)
        self.page_number = 1
        return self

    def drawRect_(self, rect):
        NSColor.blackColor().set()
        NSRectFill(rect)
        # CGContext
        ctx = NSGraphicsContext.currentContext().CGContext()
        page = CGPDFDocumentGetPage(self.pdfdoc, self.page_number)
        #
        # Draw (x1)
        #CGContextDrawPDFPage(ctx, page)
        #
        # Resize Draw
        CGContextSaveGState(ctx)
        # start
        m = CGPDFPageGetDrawingTransform(page, kCGPDFMediaBox, rect, 0, True)
        CGContextConcatCTM(ctx, m)
        # background color is white
        cgrect = CGPDFPageGetBoxRect(page, kCGPDFMediaBox)
        NSColor.whiteColor().set()
        CGContextFillRect(ctx, cgrect)
        # clip
        CGContextClipToRect(ctx, cgrect)
        CGContextDrawPDFPage(ctx, page)
        # end
        CGContextRestoreGState(ctx)

class ComipoliWindow(NSWindow):
    def init(self):
        frame = NSMakeRect(100, 400, 900, 600)
        objc.super(ComipoliWindow, self).initWithContentRect_styleMask_backing_defer_(
            frame,
            NSTitledWindowMask |
            NSClosableWindowMask |
            NSResizableWindowMask |
            NSMiniaturizableWindowMask,
            NSBackingStoreBuffered,
            False)
        self.setTitle_('draw PDF')
        self.setDelegate_(self)
        # view
        self.canvas = ComipoliView.new()
        self.canvas.setFrameSize_(frame.size)
        self.contentView().addSubview_(self.canvas)
        return self

    def nextPage(self):
        if self.canvas.max_page > self.canvas.page_number:
            self.canvas.page_number += 1
            self.canvas.display()
        if self.canvas.max_page == self.canvas.page_number:
            self.setTitle_('end')

    def windowDidResize_(self, sender):
        aw = self.contentView().frame().size.width
        ah = self.contentView().frame().size.height
        size = NSMakeSize(aw, ah)
        self.canvas.setFrameSize_(size)

class ComipoliMenu(NSMenu):
    def init(self):
        objc.super(ComipoliMenu, self).init()
        item_app  = NSMenuItem.new()
        self.addItem_(item_app)
        menu_app = NSMenu.new()
        item_app.setSubmenu_(menu_app)
        item_next = NSMenuItem.new()
        item_next.initWithTitle_action_keyEquivalent_('NextPage', 'nextPage:', 'n')
        item_next.setTarget_(self)
        menu_app.addItem_(item_next)
        item_quit = NSMenuItem.new()
        item_quit.initWithTitle_action_keyEquivalent_('Quit', 'terminate:', 'q')
        menu_app.addItem_(item_quit)
        return self

    def nextPage_(self, sender):
        NSApp.keyWindow().nextPage()

NSApplication.sharedApplication()
window = ComipoliWindow.new()
window.makeKeyAndOrderFront_(window)
NSApp.setMainMenu_(ComipoliMenu.new())
NSApp.activateIgnoringOtherApps_(True)
NSApp.run()

command+N で次ページ

って、ほとんど変わらないヤン!
結局この作業を NSPDFImageRep は内部でやっているだけなんだろう。
軽くしたきゃ cbz に変換してくれで済ませよう。

そんなことより。
PyObjC ってこんなことまでできるのかい!
書いた筆者が驚いた、Xcode マジでいらね。

NSWindow “New Window”

NSWindow をもう一つ作成するメニューを付ける。
実行すると一瞬表示されて即座に消えて無くなる。
NSApp.windows プロパティの数は増えていない、何故だ?

backingType – Cocoa API解説(macOS/iOS)

全然関係ないページだけどこの Swift コードは何?
作った Window は自前の配列とかで保持しなきゃいけないのかよ。

NSApp は作成した Window を保持してくれないってことみたい。
GtkApplicationWiondow と違い引数に app 指定が無いからそりゃそうかと。
つまり、ガベージコレクションで破棄されてしまうってことだ。

class ComipoliWindow(NSWindow):

    # Avoid garbage collection
    avoid_gc = []

    def init(self):
        frame = NSMakeRect(100, 400, 800, 480)
        objc.super(ComipoliWindow, self).initWithContentRect_styleMask_backing_defer_(
            frame,
            NSTitledWindowMask |
            NSClosableWindowMask |
            NSResizableWindowMask |
            NSMiniaturizableWindowMask,
            NSBackingStoreBuffered,
            False)
        # etc...
        return self

    @classmethod
    def new_window(self):
        window = ComipoliWindow.new()
        self.avoid_gc.append(window)
        window.makeKeyAndOrderFront_(window)
        return window

グローバル変数にしたくないからこんな classmethod を作ってみた。
大量にウインドウが作られるのものではないならアプリ終了まで保持でいい。

applicationDidFinishLaunching で Window が作れないって検索で見かける。
Objective-c, Swift も参照カウンタだから同じなんだろう。
初心者コードでは NSApp.run の前にウインドウを作るから破棄されないだけ。

後、NSMenu に View を定義すると勝手に ‘Show Tab Bar’ が挿入される。
‘Enter Full Screen’ も入るけど command+control+F は使えない。

iOSな日々: macOS Sierra非互換:「表示」メニューに「タブバーを表示」項目が自動的に追加される(非表示化対応)

なんて迷惑な仕様なんだよ。
‘Enter Full Screen’ は自前で command+control+F を定義すると消える。

#!/usr/bin/env python3

from AppKit import *
from comipoli_window import ComipoliWindow
from comipoli_menu import ComipoliMenu

class AppDelegate(NSObject):
    def applicationDidFinishLaunching_(self, notification):
        ComipoliWindow.new_window()

def main():
    NSApplication.sharedApplication()
    NSApp.setMainMenu_(ComipoliMenu.new())
    NSApp.setDelegate_(AppDelegate.new())
    NSApp.activateIgnoringOtherApps_(True)
    # Hide Auto Tab Bar
    NSWindow.setAllowsAutomaticWindowTabbing_(False)
    # run
    NSApp.run()

色々なんとかなった。

しかし、AppKit は GTK+ より日本語情報が多いのはいいんだけーが。
みんな Xcode の使い方ばかり勉強して肝心のコードがほとんど無いよな。

統合開発環境ってコードだけでアプリが作れる人が使って初めて便利だと思うもの。
であるはずなんだけど、これではプログラミングが面白いと思う人なんて増えないよ。

Python3 subprocess

筆者のアプリは python3 なのに Gio の subprocess を使っていた。
Gjs, Vala, C 等からでも参考になるよう、てか途中まで Gjs 製だった。

JXA では後で Swift をやるかもと NSTask を使っていた。
やらねーし、Xcode デカすぎだし Python のほうが面白いし。
PyObjC 版は Python3 の subprocess モジュールでいいんでね?

17.5. subprocess ? サブプロセス管理 ? Python 3.6.5 ドキュメント

知らない間に subprocess が超単純化されていた。
Popen とか call とか迷わず run だけでいい、破棄も wait もいらない。
unrar, 7za を使うコードを subprocess.run に書き換えしてみる。

#!/usr/bin/env python3

import objc, zipfile, re, subprocess
from AppKit import *

PICEXT = '\.(jpe?g|png|gif)$'

class ComipoliArchive:
    def __init__(self):
        self.status = 0
        self.namelist = []
        self.path = ''

    def new_archive(self, path, is_unrar, is_7za):
        self.path = path
        if re.search(r'\.(cbz|zip)$', path, re.I):
            self.status = 0
            self.namelist.clear()
            with zipfile.ZipFile(path) as o:
                l = o.namelist()
                l.sort()
                for name in l:
                    if re.search(PICEXT, name, re.I):
                        self.namelist.append(name)
        elif is_unrar and re.search(r'\.(cbr|rar)$', path, re.I):
            self.status = 1
            self.namelist.clear()
            cp = subprocess.run(['unrar', 'vt', '-p-', '--', path], stdout=subprocess.PIPE)
            lines = cp.stdout.decode('utf-8').split('\n')
            for line in lines:
                s = line.lstrip()
                if s.startswith('Name: '):
                    name = s[6:]
                    if re.search(PICEXT, name, re.I):
                        self.namelist.append(name)
        elif is_7za and re.search(r'\.(cb7|7z)$', path, re.I):
            self.status = 2
            self.namelist.clear()
            cp = subprocess.run(['7za', 'l', '-slt', path], stdout=subprocess.PIPE)
            lines = cp.stdout.decode('utf-8').split('\n')
            for line in lines:
                if line.startswith('Path'):
                    name = line[7:]
                    if re.search(PICEXT, name, re.I):
                        self.namelist.append(name)
        else:
            return False
        return True

    def __getitem__(self, num):
        if self.status == 0:
            with zipfile.ZipFile(self.path) as o:
                b = o.read(self.namelist[num])
                data = NSData.dataWithBytes_length_(b, len(b))
                return NSImage.alloc().initWithData_(data)
        elif self.status == 1:
            cp = subprocess.run(['unrar', 'p', '-inul', '-@', '--', self.path, self.namelist[num]], stdout=subprocess.PIPE)
            data = NSData.dataWithBytes_length_(cp.stdout, len(cp.stdout))
            return NSImage.alloc().initWithData_(data)
        elif self.status == 2:
            cp = subprocess.run(['7za', 'x', '-so', self.path, self.namelist[num]], stdout=subprocess.PIPE)
            data = NSData.dataWithBytes_length_(cp.stdout, len(cp.stdout))
            return NSImage.alloc().initWithData_(data)

    def __len__(self):
        return len(self.namelist)

丸ごと。

unrar の存在確認も subprocess でやろうと思ったけど。

Pythonで外部コマンドの存在チェック(`which`的な) – Qiita

self.is_unrar = shutil.which('unrar') != None

こっちのほうがいいや。
早速試すことにしよう。

WinRAR archiver, a powerful tool to process RAR and ZIP files

から macOS 版 rar のダウンロード。
インストーラーも Makefile も無いので手動でパスを通す。
終わったら cbr にしたい画像の入ったディレクトリで以下を。

rar a test.cbr -- *.jpg

直接 CBR ファイルを作れるよ。

イケた。

Python3 自体の知識が 3.2 くらいで止まっているわ。
最新 Python3 必須になるけど、いやそのほうがいいでしょ普通。
たまには公式サイトも見ることにしよう。

sips

Retina Display の mac でスクリーンショットする。
mac で見る分にはいいけど Blog にアップするとデカくてビビる。
前回のスクショの詳細を見ると 144dpi もある、通常の二倍だ。

全画面だと 2880x1800px になるからたしかに二倍だね。
色々検索するとみんな困っているようで、何故こうしたのやら。

macos – How can I stop my retina display from taking 2x sized screenshots? – Ask Different

sips という素敵なコマンドがあるようだ。
man を見ると BSD がとか出るけど最後に Darwin の文字が。
Fedora の dnf list にも出ないしやはり macOS 専用のようです。

早速クイックアクションに登録。
コピペだと上書きになるので別名保存処理を入れてみた。
コマンドではないからディレクトリ名を得る必要があるな。

for f in "$@"
do
    width=$((`sips -g pixelWidth "$f" | cut -s -d ':' -f 2 | cut -c 2-` / 2))
    dir=${f%/*}
    name=`date "+72dpi-%Y%m%d-%H:%M.%S.png"`
    sips -s dpiHeight 72.0 -s dpiWidth 72.0 -Z $width "$f" --out "$dir/$name"
done

name はお好みに。

値は正常になるけどなんかボケるなぁ。
でもこんなもんでいいだろう、今回よりコレ使います。

@objc.python_method

macOS に入っている unzip はパッチが当たっていない Unicode 非対応な奴。
NSTask で使うと日本語でエラーになるので制限を付ける必要があった。

PyObjC では Python3 の zipfile モジュールを使えば何も問題なくね?
と思って使ってみたら、やっぱり文字化けしたw
でもコッチなら文字化けしたままエラーにならず画像を取り出しできるじゃないの。
よし unzip 問題は解決だ。

しかし PyObjC プログラミングは結構きつい!
まったく例外を吐いてくれない、エラーになったらフリーズするだけ。
チビッと書いて動かしてを繰り替えさないとドコでエラーになったか全然ワカンネ。

例外がウルトラ親切な PyGObject ってマジ神仕様だよ。
アレで何も作れなかったら他の環境なんて絶対に無理と断言できるくらい。

それとサブクラスにはまいった。

FieldGraph ? PyObjC ? the Python ? Objective-C bridge

NSObject を継承するとメソッドの定義に制限を食らう。
つまりメソッドの定義は Objective-c の関数変換と同様にする必要あり。
引数の数だけアンダーバーが必須、最後にアンダーバーが必要だとか。

#def change_pixbuf(self, num): # Error
def changePixbuf_(self, num):

みたいな。

@objc.IBAction はデフォルトだから書く必要は無いみたい。
Python のメソッドらしいスネークケースにするには

@objc.python_method
def change_pixbuf(self, num):

のデコレーターが必須。

ハンドラと関数はやっぱり別表記にしたいよね。
それを見つけるまで半日近く、日本語情報は皆無に近いし。

Python で作っても NSOpenPanel で警告が出るじゃん。
jxa てか osascript のせいでは無かったようで。

そんなんでもなんとかココまで作れた、ページめくりもできる。
まだ超作りかけだけど参考用とバックアップを兼ねて置いとく。
comipoli_pyobjc1.tar.gz