高見知英の技術ログ

技術関係のログをQiitaから移行してきました。プログラミングのほか、使っているアプリの細かい仕様についてなど書いていきます。

PyWebViewのURLパラメータにHTTPサーバを指定したときの挙動について

結局ソースコードを追ってデバッグ実行してなんとか対応できた・・・。

PyWebViewというPythonのモジュールを使うと、HTMLをPythonデスクトップアプリのGUIに使うことができます。

pywebview.flowrl.com

webview#create_window()というメソッドを使って生成したウィンドウにWindow#load_html()メソッドでHTMLを流し込めば、HTMLをGUIに使うことが可能です。

最近のバージョンでは、EdgeChromiumをデフォルトのレンダリングエンジンに使うようになった。

さて、電子書籍を作成するにあたり、PyWebViewを使っているこちらのアプリを久々にいじることになりまして。

github.com

とりあえずいつもの通りモジュールを最新化して、変更されたAPIにちょこちょこ対応しても、何故か動かない。なんでだろう?と思って調べてみたところ。

  • 最近のPyWebViewのバージョンでは、Windows上でのデフォルトレンダリングエンジンとして、mshtml(Trident)からEdgeChromium(Chromium) を使うようになった
  • mshtmlはドライブパスからはじまるフルパスを指定して、HTMLファイル内からCSSJavaScriptなどのリソースを読めたが、ChromiumEdgeではできなくなった(「Not allowed to load local resource」というエラーがブラウザのコンソールに表示される)

ということで、PyWebViewに備えられているもう一つの機能、「内部HTTPサーバを使う」という機能を使ってみることになりました。

PyWebViewで、内部HTTPサーバを使う

PyWebViewのマニュアルを読むと、webview#create_window()の第二引数(URL)にWSGI互換のHTTPサーバを指定すると、ページの処理時にそれを使うという機能があることが書かれています。

pywebview.flowrl.com

ページの使用例ではFlaskのHTTPサーバ機能を使っているようですが、もちろんWSGI互換のHTTPサーバであれば、指定が可能なはずです。

今回はFlaskを使うほどの大規模なことをするわけではないので、独自にHTTPサーバを作って処理しようと思ってやってみました。

ドキュメントに書かれていないこと

ここからがだいぶPyWebViewのドキュメントに書かれていないことで、ここで指定するHTTPサーバは、以下の条件を満たしている必要があります。

  • __webview_urlというアトリビュートが宣言されている必要がある(PyWebViewから値が書き込まれるだけなので中身はどんな値でも構わない)
  • __call__メソッドを実装している。

正直わたし自身WSGI互換のHTTPサーバというものがどういうものかよくわかっていないところもあるのですが(特定のクラスを継承しておく みたいなこともないようですし)、このへんを実際にデバッグ実行してみないと分からないという仕様はこのモジュールの厳しいところだなあと。

webview#create_window()の引数にHTTPサーバを指定してから起こること

webview#create_window()の引数にHTTPサーバを指定したあと、そのウィンドウが画面に表示されると、ただちにドキュメントルート(/)へのアクセスを指定して、__call__()メソッドが呼び出されます。

class Server:
  def __init__(self) -> None:
    self.__webview_url = ""
  ###
  def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Any:
    pass  # /// 処理 ///
  ###

このとき第一引数であるenvironには呼び出し時の環境変数のリストが(リクエストのURI(パス)は、environ["PATH_INFO"]に格納されています)、第二引数のstart_response()には、実際にレスポンス処理を行なうために呼び出す関数が格納されています。

ここでパスを解析してHTMLファイルなどのリソースを読み込み、返すには、次のようなコードを書けば良い。

import mimetypes

class Server:
  def __init__(self) -> None:
    self.__webview_url = ""
  ###
  def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Any:
    path = environ["PATH_INFO"]
    filepath = self.findfilepath(path)
    content = None
    headers = []
    status = ''
    if not filepath is None and filepath.exists():
      filetype, encoding = mimetypes.guess_type(filepath)
      with open(filepath, "rb") as f:
        content = f.read()
        status = '200 OK'
        headers = [('Content-type', f'{filetype}; charset=utf-8' if filetype.startswith("text") else filetype)]
    else:
      status = '404 Not found'
      content= b'Not found'
    start_response(status, headers)
    return [content]

  def findfilepath(self, path: str) -> Path:
     pass # パスの解析処理…

mimetypesとは、Python標準モジュールの一つで、引数に指定したパスのMIMEタイプを返してくれるという便利なモジュールです。

ファイルの中身を見ないとか拡張子のないファイルに対応してないとか.jsをtext/plainとして返してしまうとかいろいろ問題はありますがとりあえずここで使うぶんには問題なし。

ここで作成したクラスをwebview#create_window()の第二引数で使用してやれば、とりあえずサーバとしての処理を行なうことが可能です。

なお、このメソッドは複数のスレッドから同時に呼び出されることになるため、複数行にまたがる処理(ログの出力など)は複数のスレッドの出力がバラバラになってしまう可能性があります。

デバッグ実行した場合も同じなので、ここのメソッドをデバッグ実行すると混乱します。あまりここでデバッグしないようにしましょう。

とりあえずこのへんを守っていれば、PyWebViewを使ったアプリケーションが作りやすくなると思います。

扱い方次第でかなり柔軟な表示もできるので、PyWebViewを使いこなすつもりであれば試してみても良いでしょう。

しかしこう、なんというか。Pythonのモジュールは後方互換性確保のためか知りませんがTypeHintがろくについてないものがおおいですね。内部APIをいじるときにどう書いたら良いか迷うので困ります…。

とくに今回みたいに、「詳細な挙動はjustMyCodeフラグをオフにしてVSCodeからデバッグしてみないと分からない」というのは正直つらいのです。

参考資料

成果物のリポジトリ

該当のコードはRe-VIEW-Preview/server.py at master · TakamiChie/Re-VIEW-Previewあたりにあります。

なおこのツール自体はRe:Viewの原稿をHTMLとしてプレビュー表示できるツールです。まだ安定版とはとても言えませんがRe:Viewを使っている方はこちらも見てみてください。

stand.fmの音声に独自でBGMを貼り付けるツールを作った

表題の通りですが、先日stand.fm用に録音した音声に、独自に作ったBGMを貼り付けるツールを作りました。

github.com

やってることはそんなに難しいことではなく…

  1. あらかじめBGMに使えるループ可能なBGMを用意する(1分程度)
  2. BGMと録音した音声をPythonのPydubモジュールで読み込む(pydub.AudioSegment#from_mp3())
  3. 録音の長さ+イントロ+アウトロぶんの長さ、BGMを敷き詰めた音声を作る(AudioSegment#__add__(AudioSegment))
  4. 3で作った音声のイントロ終了後~アウトロ開始直前までのボリュームを小さくする(AudioSegment#__add__(int), pydub.utils.ratio_to_db)
  5. ボリュームを小さくした部分に、録音した音声を合成する(AudioSegment#overlay())
  6. 5でできた音声をファイルに0000.mp3として保存する(AudioSegment#export())
  7. おまけに、元の録音音声に含まれるID3タグをすべてコピーする(mutagen.id3.ID3クラス)

という感じです。手順3の音声を作るときなど多少の計算が必要にはなりますが、とりあえずかなり簡単に音声ファイルをいじることができます*1

そもそもなぜそんなことをしたのか

stand.fmには、アプリ側で用意してあるBGMを設定する機能があります。なので、stand.fmにのみ音声を投稿する場合、BGM設定は不要です。

ただわたしは、stand.fmに公開した音声をAnchor.fmにも公開したい と思ったので、独自にBGMをつけることになりました*2

というわけで、公開したのが以下となります。音声聞いてみたいけどstand.fmアカウントないしなー と言う人や、ポッドキャスト愛好家でいちいちstand.fmで音声聞きたくない と言う人はどうぞ。

anchor.fm

作った感想

とりあえずPydubを使ってみて、ここまで簡単にできるのかー と思いました。ついでにBGMも幾つか作ったので日替わりBGMを実現することができました(BGMの作成には、LaunchpadというiPad用のアプリを使いました)。

ちゃんとフェードイン・フェードアウトやゲイン調整などちゃんとしたものを実装しようとするとそこそこに手間が掛かりそうですが、とりあえずここまではできるんだな と言うことがわかりました。

注意点

これといって注意といえる注意点はないのですが・・・。

強いて言うなら、Pydubは場面によってミリ秒単位で時間を扱うときと、秒単位で時間を扱うときがあるので、それを混同しないように気をつける必要があります*3

音声編集にも使えるかも

うちではstand.fmのほか、SBCast.やUdemy音声などの音声編集も行なっています。ゲインの調整などの定型処理が挟まることもあり、微妙に面倒だなあ と思っていました。

ただ、ひょっとするとこれはこのへんの分野にも使えるのかもしれないなあ と思いました。

今後Pydubを他でも使う機会があれば、また使ってみようかなあ と思います。

参考文献

*1:なお、mp3の読み込みにはFFMPEGが必要です。Windowsの場合Chocolateyで入れると楽です

*2:Anchor.fmにもBGMをつける機能はありますが、その機能を使うと双方で別のBGMがかかってしまうので

*3:音声を切り貼りするときはミリ秒、音声データの長さを処理するときは秒

PDFのサイズ=インチサイズの72倍ではない

はじめに言おう。PDFは悪だ

まあそれはそれとして。ある日プログラムでPDFを作成する必要がでてきました。PDFの内部単位はインチサイズの72倍となっており、単純に考えれば72dpiのピクセル値を入れればよさそうです。

docs.leadtools.grapecity.com

www.benricho.org

ただ実際にやってみると、この通りにPDFを作成しても思い通りのかたちにならず。

Adobe Readerなどで読ませると何かサイズが違う。0.nミリ単位でずれる。これはなぜだろう?と思っていろいろ調べていました。

結果、PDFのサイズ=インチサイズの72倍ではないということが分かりました。

AnサイズのPDFサイズについて

これについてはルールみたいなものがよくわからなかったので、一覧です。このへんはしらみつぶしで探しました。

  • A0: 2383.87, 3370.23
  • A1: 1683.84, 2383.87
  • A2: 1190.52, 1683.84
  • A3: 841.92, 1190.52
  • A4: 595.32, 841.92
  • A5: 419.64, 595.32
  • A6: 297.66, 419.64
  • A7: 209.82, 297.66
  • A8: 147.57, 209.82
  • A9: 104.91, 147.57
  • A10: 73.82, 104.9

前の数値が短辺(縦長の場合は横)、あとの値が長辺(横長の場合は縦)となります。

続きを読む

ゲームサントラのトラック番号をディスクごとの値でなくサントラでの連番に変換するスクリプトを書いた

先日こちらを買いましてん。

ゲームのサウンドトラックを買ったことがある方はわかると思いますが、これ、PCにリッピングしてMP3ファイルにすると、トラック番号がサウンドトラック全体の連番ではなく、ディスクごとの連番になってしまうんですよね。

で、このサントラはゲームでも珍しい7枚組のサウンドトラックなので、手作業で修正するのはあまりにも手間なのです。

ということで、せっかく前回MP3のID3タグを参照するモジュールを使ったので、今回もpythonで一括修正スクリプトを書きました。

github.com

いちおうREADMEにも書いていますが、とりあえずpipenv installで必要なモジュールがインストールされますので、トラック番号を設定したいファイル群をフォルダごと配置し、スクリプトを実行するだけです。

手抜きのため、フォルダの指定やファイル順序の設定などはしていません。

やってること

やっていることは単純。カレントフォルダ配下をすべて検索し、mp3ファイルを探します。

そして見つけられたらID3タグを確認し、トラック番号を連番に書き換えます。

def scan(trackno: int, dir: Path):
  for f in dir.iterdir():
    if f.is_dir():
      trackno = scan(trackno, f)
    elif f.is_file() and f.name.endswith("mp3"):
      tags = EasyID3(f)
      oldtrack = tags["tracknumber"]
      tags["tracknumber"] = f"{trackno}"
      print(f"> {f.name} {oldtrack} -> {trackno}")
      tags.save()
      f.rename(f.parent / re.sub(r"^\d+(-\d+)?\s*", "", f.name))
      trackno += 1
  return trackno

if __name__ == "__main__":
  scan(1, Path("."))

もしフォルダだった場合は、再帰的にそのフォルダ内も掘っていきます。ついでにファイル名にトラック番号がついていたら、削除します。

本当はアルバムの[Disc 1]などの記述も削除したかったのですが、文字列のデコードがうまくできなかったのでそのままとしました。必要であればあとでエクスプローラーから編集します。

終了後、すべてのファイルを操作したい場合

Windows Media Playerなどでリッピングした場合、初期設定だとサントラはディスクごとに別々のフォルダに保存されているはずなので

エクスプローラーの検索機能より「種類:オーディオ」で検索します。 f:id:TakamiChie:20210318113647p:plain

わたしの場合、音楽は一括して「D:\Users\ユーザー\Music\ゲーム\」に保存しているので、そこにコピーしました。

f:id:TakamiChie:20210318113805p:plain

はい、できあがり

CertUtilは巨大なファイルを処理することができない

とある事情によりファイルをBASE64エンコードする必要が生じました。いちいちソフトを入れるのは面倒くさいのでバッチファイルだけで処理できないかと探してみたところ、CertUtilというWindows標準のツールがBASE64変換の機能を持っているとのこと。

qiita.com

しかし実際使ってみると、ファイルによっては期待通り動かず、0バイトのテキストファイルを出力するだけして止まってしまうことがある模様。

とりあえず5GBの動画ファイルを読み込ませようとすると、以下のログが表示されます。

> certutil -f -encode .\,movie.mp4 .\movie.base64.txt
入力長 = 1816822796
出力長 = 0
CertUtil: -encode コマンドは正常に完了しました。

出力長が0ってどう見ても正常に完了してないんですが・・・。とりあえず原因はもう少し小さいファイルを送りつけると表示されます。

> certutil.exe -f -encode .\movie.zip.001.img .\movie.base64.txt
入力長 = 838860800
EncodeToFile は 算術結果が 32 ビットを超えています。 0x80070216 (WIN32: 534 ERROR_ARITHMETIC_OVERFLOW) を返しました
CertUtil: -encode コマンド エラーです: 0x80070216 (WIN32: 534 ERROR_ARITHMETIC_OVERFLOW)
CertUtil: 算術結果が 32 ビットを超えています。

と、いうことで、具体的な限界値は分かりませんが、CertUtil -f -encodeは巨大なファイルを読み込もうとするとエラーを吐くようです。

よって、巨大なファイルをエンコードしたい場合は面倒でも別言語でプログラムするなり外部ツールを使う必要があります。結局いろいろ苦心した結果この対応は必要なくなったためこれ以上のことはしていませんが。

ということで、バッチファイルでファイルをBASE64エンコードしようとしたのに上手くいかない というときは、ファイルサイズに気をつけてみてください というお話しでした。

PowerShellでPowerPointのスライドをセクション別のファイルに分割する

とある事情で、PowerPointの以下のようなファイルを、セクションごとに別々のファイルに分けることになりました。

  • セクション1 # →セクション1.pptxとして保存
    • スライド1
    • スライド2
  • セクション2 # →セクション2.pptxとして保存
    • スライド3
    • スライド4
    • スライド5
  • セクション3 # →セクション3.pptxとして保存
    • スライド6

セクション三つくらいであれば別に手作業でもいいんですが、17セクションもあるとけっこうツラいので、PowerShellで作成することにしました。

OfficeアプリのCOMオブジェクトはリファレンスが追いづらく、あまり好きではないのですが・・・*1

というわけで

というわけでひとまずコードです。

$file = "$PSScriptRoot\雉・侭.pptx"
$app = New-Object -ComObject PowerPoint.Application
$pres = $app.Presentations.open($file)
$sec_name = ""
$curpres = $null
foreach($s in $pres.Slides){
  $cur_sec = $pres.SectionProperties.Name(($s.sectionIndex))
  if($sec_name -ne $cur_sec){
    if($sec_name -ne ""){
      # 前のプレゼンテーションファイルを保存して閉じる
      $curpres.SaveAs("$PSScriptRoot\$sec_name.pptx")
      $curpres.Close()
    }
    # 新しいプレゼンテーションファイルを作成する
    $curpres = $app.Presentations.Add()
    $sec_name = $cur_sec
  }
  if($null -ne $curpres){
    # スライドをコピーする
    $s.Copy()
    $newslide = $curpres.Slides.Paste()
    # 各種パラメータのコピー
    $newslide.Design = $s.Design
    $newslide.ColorScheme = $s.ColorScheme
    $newslide.DisplayMasterShapes = $s.DisplayMasterShapes
    $newslide.FollowMasterBackground = $s.FollowMasterBackground
  }
}
$curpres.SaveAs("$PSScriptRoot\$sec_name.pptx")
$curpres.Close()

$pres.Close()
$app.Quit()

とりあえず個々のメソッド解説はおいといて、すべてのスライドはPresentation.Slidesプロパティ、セクション名はPresentation.SectionPropetoesプロパティで取得できます。

案外Visual Studio Code上でもPowerShellスクリプトが編集できるということが分かって良かった(たしか要拡張機能)。PowerPointApplication.VisibleプロパティをmsoFalseにすればPowerPointアプリ自体を非表示にできる と書いてあったので試したものの、うちのバージョンではエラーが出て非表示にできなかった。

どれくらいの精度でコピーできる?

とりあえず、完璧ではない。

実際にスクリプト実行後すべてのファイルを見てみたものの、

  • コピーができなかったスライド(原因不明):1つ
  • レイアウトが崩れてしまった)スライド:2つ

あった。まあ92スライド中3スライドだけだったので上出来か。

レイアウトが崩れてしまう理由

レイアウトについてはスライドマスターの状態から多少でもレイアウトをいじっていると、確実に崩れるようで、スライドマスターに登録されていないレイアウトを使うと確実にずれます。

例えばわたしが良く使う上下に2コンテンツみたいなレイアウトとかをするときは、必ずそのレイアウトをスライドマスターに登録しておく必要があります(スライドマスターに登録してあれば問題ありません*2 )。

今回上手くいかなかったのは、スライドマスターの設定を変更し忘れたり、微妙にレイアウトを変更していたスライドでした。

スライドの確認は必須。でも効果は十分

はいっているはずのスライドがはいっていなかったり、レイアウトが崩れてしまったりなど、予期せぬ問題があるため、スライドの確認は必須。ただツールとしては十分有益だったかなと思います。

ComObjectの利用例は少ないためツールを作るのもそこそこに大変ですが、とりあえず使ってみる価値はあるかなと。

*1:PowerPointのリファレンスだっていうのにAccessとかExcelの情報とか平気で引っかかるし、一部のメソッド名が翻訳されてたりするし

*2:それでもそこから少しでもレイアウトをいじっているとやっぱりずれます

Pythonは複数変数に同時に値を設定できる

たいしたことではないですが覚えておくといろいろ役に立つのでメモ。

Pythonでは、関数の戻り値をtupleで得るなど、複数の値を1度に取得することがたまにあります。

たとえばtkinterでウィンドウの寸法を取得するには、window.geometry()で取得できる値を分割するのが簡単

  import re
  sizes = re.split("[x+]", window.geometry())

このときsizesの配列には、

  • 0: 幅
  • 1: 高さ
  • 2: X位置
  • 3: Y位置

という値がはいるのですが、同じようなコードが連続するとどれがどの値なのかぱっと見分かりづらくなる。

そんなときはこうすると良い。

  w, h, x, y = re.split("[x+]", window.geometry())

こうすると変数w,h,x,yにそれぞれの値がはいるので管理が楽。さらにintにするには

w, h, x, y = map(lambda s: int(s), re.split("[x+]", window.geometry()))

とすれば良い。tupleやlistに直さなくても良いんだ…。

どうということはないけど覚えておくと意外と役に立つ書き方でした。