Moodleのプラグインを作ってみる

目的

MoodleというeLearningシステムがあります。
PHPとMySQLが動けば、わりと簡単に導入することが可能です。

今回はこのMoodleのプラグインを作成してみます。

syntaxhighlighterを参考に作成しています。

ソースコード
https://github.com/mima3/aa_image/tree/master/moodle_plugin/filter_aa_image

機能

エディタで入力した「aa_image]~[/aa_image]の内容を画像に変換するプラグインをsyntaxhighlighterを参考に作成します。


以下のような入力を行ったとする。

[aa_image]
       ____ 
     /      \ 
   /  _ノ  ヽ、_  \ 
  / o゚((●)) ((●))゚o \  ほんとはVIPでやりたいんだお… 
  |     (__人__)    | 
  \     ` ⌒´     / 

[/aa_image]

[aa_image]~[/aa_image]に記載された内容を圧縮してコードに変換する。

e9zQoPAYgiHo%2FZ79EBSvwIUm0Q9hwNS%2F37MHrARZWiH%2BcXMfWE%2Fz3vd7l8SDhGDqgIx%2Bhfz3%2B%2BZrvN%2FT8Wh6%2F%2Fs9nZoKSGygTD5YMUh74%2B7HTZMfN6543Lg%2BzDPgcePyx00tj5u6HjfOf9zYApZa8Lix61HDMojRNXB3QCzqiI9%2FsmtXfDzQXGSZGphD9iB55f3eBoVHPZMObVGAiwAdysUFAA%3D%3D

このURLをパラメータとしてIMGタグを作成する。

<img src="https://needtec.sakura.ne.jp/aa_image/image?d=e9zQoPAYgiHo%2FZ79EBSvwIUm0Q9hwNS%2F37MHrARZWiH%2BcXMfWE%2Fz3vd7l8SDhGDqgIx%2Bhfz3%2B%2BZrvN%2FT8Wh6%2F%2Fs9nZoKSGygTD5YMUh74%2B7HTZMfN6543Lg%2BzDPgcePyx00tj5u6HjfOf9zYApZa8Lix61HDMojRNXB3QCzqiI9%2FsmtXfDzQXGSZGphD9iB55f3eBoVHPZMObVGAiwAdysUFAA%3D%3D"/>

コードの説明

ファイル名 説明
filter.php 出力前にコンテンツを自動的に変換する処理を記述。詳細はFiltersを参照
settings.php 設定画面の実装。詳細はsettings.phpを参照
thirdpartylibs.xml 使用しているサードパーティのライブラリの情報を記載するXMLファイル。詳細はthirdpartylibs.xmlを参照
version.php バージョン情報。詳細はversion.phpを参照
classes/privacy/provider.php 個人データを扱うか扱わないという情報をMoodleに通知するためのインターフェイス。詳細はPrivacyAPIを参照
lang/en/filter_aa_image.php 多言語対応用のファイル。画面に表示する文字列を各言語毎に用意する

settings.php

https://github.com/mima3/aa_image/blob/master/moodle_plugin/filter_aa_image/settings.php

設定画面は以下のような実装を行います。

if ($ADMIN->fulltree) {
    $setting = new admin_setting_configtext(
        'filter_aa_image/service_url',
        new lang_string('serviceurl', 'filter_aa_image'), 
        new lang_string('serviceurl_desc', 'filter_aa_image'), 
        'http://needtec.sakura.ne.jp/aa_image/image');
    $settings->add($setting);
}

この実装を行った設定ページは以下のように表示されます。

admin_setting_configtextは設定画面にテキスト入力できるコントロールを追加します。
第一引数の「filter_aa_image/service_url」は設定の名前を指定します。以下のような書式になります。

プラグイン名/一意の名称

第二引数はテキストの横に表示されるラベルの表示文字になります。
ここでは直接文字を入力することなく、lang_stringを使用して多言語対応をしています。

第三引数は説明の表示文字になります。
ここでは直接文字を入力することなく、lang_stringを使用して多言語対応をしています。

第四引数はデフォルトの値になります。

その他詳細は下記のページを参照してください。

Creating a theme settings page
https://docs.moodle.org/dev/Creating_a_theme_settings_page

filter.php

出力前にコンテンツを自動的に変換する処理を記述します。

class filter_aa_image extends moodle_text_filter {

    public function filter($text, array $options = array()) {
        if (!is_string($text) || empty($text)) {
            return $text;
        }

        $re = "~\[aa_image\](.*?)\[/aa_image\]~isu";
        $result = preg_match_all($re, $text, $matches);
        if ($result > 0) {
            foreach ($matches[1] as $idx => $code) {
                $code = str_replace(
                    ['>','<','<pre>', '</pre>', '<p>', '</p>', '<br>', ' '],
                    ['<', '>','', '', '', "", "\n", " "],
                    $code);
                $key = base64_encode(gzdeflate($code, 9));
                $newcode = '<p><img src="' . get_config('filter_aa_image', 'service_url') . '?d=' . urlencode($key). '"/></p>';
                                $text = str_replace($matches[0][$idx], $newcode, $text);
            }
        }

        return $text;
    }

moodle_text_filterを継承したクラスでfilterメソッドを実装することで実現できます。
やっている内容は[aa_image]で始まって[/aa_image]で終わる箇所を抜き出して置き換えをしています。
設定画面で設定した内容はget_configを使用して取得できます。

プラグインのインストール方法

下記のフォルダをZIP形式で圧縮します。
https://github.com/mima3/aa_image/tree/master/moodle_plugin/filter_aa_image

この圧縮したZIPをmoodleにアップロードします。

管理者でログインした後にサイドメニューの「サイト管理」を選択後、「プラグイン」タブを選びます。そこから「プラグインをインストールする」を選びます。

ZIPパッケージにZIPファイルをドラッグアンドドロップしたのちに、「ZIPファイルからプラグインをインストールする」を選択します。

警告の内容を確認し、問題なければインストールを続けます。

moodleデータベースを更新します。

更新結果が表示されるので成功の場合は「続ける」を選択します。

プラグインの管理画面が表示されるので必要に応じて設定を変更して「変更を保存する」を選択します。

この時点でプラグインのインストールは完了しましたが、プラグインの機能は有効化されていないません。

プラグインの有効化の手順

管理者でログインした後にサイドメニューの「サイト管理」を選択後、「プラグイン」タブを選びます。そこから「プラグインの概要」を選びます。

プラグイン名で検索して、そのカテゴリーの「設定」ボタンを押します。今回の場合はテキストフィルタの設定になります。

プラグイン名で検索して「有効?」の列をONにします。

プラグインのアンインストール方法

管理者でログインした後にサイドメニューの「サイト管理」を選択後、「プラグイン」タブを選びます。そこから「プラグインの概要」を選びます。

プラグイン名で検索してアンインストールを実行します。

ブラウザに表示する画像を動的に作成する

目的

ブラウザに表示する画像を動的に作成するというテクニックは古来より使われてきました。
たとえば、アクセスカウンターや数式の描画などに使用されています。
今回はその使用例を見るとともに、実際に自分で作成してみることにします。

実例

アクセスカウンターの例

以下のページは無料アクセスカウンターを提供しています。
http://www.rays-counter.com/

このページでアクセスカウンターを作成すると以下のようなタグを含んでいます。

http://www.rays-counter.com/d460_f6_022/5ec064f0698a5/

アクセスカウンタ

該当のURLにアクセスがあるたびに、カウンターを増やして、その内容を画像に変換して応答しています。

mimetex

http://www.forkosh.com/mimetexmanual.html
mimetexはC言語で実装されたCGIで、使用するとimgタグを使用して数式を描画することができます。

たとえば以下のようなタグを記載します。

<img src="https://needtec.sakura.ne.jp/mimetex/mimetex.cgi?\frac{-b\pm\sqrt{b^2-4ac}}{2a}">

この内容をブラウザで確認すると以下のような画像になります。

以下のページでmimetexにあたえるパラメータを試せるようにしています。
http://needtec.sakura.ne.jp/mimetex/index.php

PlantUMLのWordPressのプラグイン

WordPressのプラグインにPlantUML Renderというものがあります。
https://ja.wordpress.org/plugins/plantuml-renderer/

以前、導入方法を紹介しましたが、PlantUML Renderの仕組みも、imgタグを使用して与えたパラメータにより画像を動的に作成することで実現しています。

また、当該プラグインはPlantUMLの公式のWebサイトのURLをりようしているので、実際にWordPressをインストールしないでも、この仕組みを確認することができます。
https://plantuml.com/en/demo-javascript-synchronous

このページは以下のようなテキストエリアと画像が存在します。

テキストエリアの内容

Bob->Alice : hello

画像
UML

この画像のURLは以下になります。

http://www.plantuml.com/plantuml/img/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000

「SyfFKj2rKt3CoKnELR1Io4ZDoSa70000」という値はテキストエリアの内容によって変化します。

たとえばテキストエリアの内容を以下のように変更して送信ボタンを押してみましょう。

ボブ->アリス : こんにちは

この場合の画像とそのURLは以下のようになります。

http://www.plantuml.com/plantuml/img/0IG0s__ZWvpZWvOjFkE2ekE3gkE2kI0w8EE1a-E2a-E1g-E1eUE1hme0

「SyfFKj2rKt3CoKnELR1Io4ZDoSa70000」という値が「0IG0s__ZWvpZWvOjFkE2ekE3gkE2kI0w8EE1a-E2a-E1g-E1eUE1hme0」に変更されたことが確認できます。
この値はテキストエリアの内容を圧縮したものになっており、サーバー側はこの内容を解凍してから、その内容に応じた画像を生成しています。

AAの画像生成プログラムを作ってみる

ではこれらの先駆者の方法を利用してAAの画像生成プログラムを作ってみます。

デモサイト
https://needtec.sakura.ne.jp/aa_image/main

コード
https://github.com/mima3/aa_image

GETの受付と応答

imgタグで指定したsrcのURLにGETでリクエストされるので、その応答を作成する必要があります。
今回はPHPの軽量フレームワークである、Slim Frameworkを使用しました。

実際に画像を生成するリクエストの箇所は以下になります。
https://github.com/mima3/aa_image/blob/master/www/index.php#L89

$app->get('/image', function (Request $request, Response $response, $args) {
    // この時点でエンコードをしてくれる
    $key = $request->getQueryParams()['d'];

    $imageModel = $this->get('imageModel');
    $image = null;
    if (strlen($key) > 1024 * 10) {
        $response->getBody()->write("文字数が多すぎるため作成できません。");
        return $response->withStatus(500);
    }

    $rec = $imageModel->get($key);
    if ($rec) {
        $image = $rec->data;
    } else {
        $image = convertImage($key, $this->get('config')['FONT_PATH'], 12);
        if (strlen($image) > 1024 * 10) {
            $response->getBody()->write("画像サイズが大きすぎるため作成できません。");
            return $response->withStatus(500);
        }

        $imageModel->append($key, $image);
    }

    // 
    $response->getBody()->write($image);
    return $response
        ->withHeader('Content-Type', 'image/png')
        ->withStatus(200);
});

やっていることとしてはconvertImageという関数でクエリーパラメータからPNG画像を作成します。
その後、その内容を$responseのwriteを使用して応答を書き込み、ヘッダーを指定してます。

使いどころ

画像としてAAを作成できるため、環境によってのズレが生じません。
たとえば、QiitaやMoodleでAAを張るとズレますが、この仕組みを利用するとズレが生じなくなります。

以下は実際に作成したmoodleのプラグインになります。
https://github.com/mima3/aa_image/tree/master/moodle_plugin/filter_aa_image

懸案事項

この手法の問題としてURLが長くなってしまうことにあります。
Hypertext Transfer Protocol — HTTP/1.1 (RFC 2616)には長さの規定はないものの、サーバーならびにブラウザに上限は存在するので、何千、何万ものパラメータを渡すことはできないでしょう。

参考:

ムッシューのIT備忘録- URLの長さの上限について
https://mussyu1204.myhome.cx/wordpress/it/?p=693

さがしていたあの曲まとめ

さがしていたあの曲

曲のフレーズはわかるが、曲名がわからなかったあの曲を調べてみました。

パンチャンドラムのテーマ

https://en.wikipedia.org/wiki/Scotland_the_Brave

スコットランドの愛国的な歌で、けっしてパンチャンドラムをたたえる歌ではない。
Wikipediaによると、19世紀後半に演奏されはじめて現在一般的に使用されている歌詞は、1950年頃に作詞されたものらしい。

ソヴィエトマーチ

偉大なるソ連をたたえる作詞作曲がアメリカ人の歌。
実はRedAlertという資本主義の犬が作ったビデオゲームに出てくる曲で別にソ連の国家ではない。

マッスロイドのテーマ

ムキ姉さんが登場する時のテーマソング。

Bon Jovi氏 の 「It's My Life」という曲。

彼がボディービルダーだったり筋肉を誇っているという事実は確認できない。

勝利確定のBGM

【ゆっくり解説】世界の奇人・変人・偉人紹介を中心によくつかわれる勝利確定時にながれるBGM.

フリーBGM素材「甘茶の音楽工房」で公開されている「残業戦士」という曲
https://amachamusic.chagasi.com/image_shissoukan.html

使われるシーンのわりには曲のタイトルが悲劇的。

鼻から牛乳

嘉門達夫達夫氏の替え歌で出てくる曲
https://www.amazon.co.jp/dp/B000064XGL

元はトッカータとフーガ ニ短調というバッハの曲。風評被害枠その1。

なお、バッハが鼻から牛乳を出したか否かは検証できない。

The Carol of the Old Ones - 旧支配者のキャロル -

元はCarol of the Bellsというウクライナの民謡を元につくられたクリスマスキャロルで別に、邪神を復活させるための歌ではない。風評被害枠その2.

住所から座標をもとめるジオコーディングの方法

目的

地名、住所が示す場所に対して、地理座標を与えるジオコーディング(geocoding)を行う方法を説明します。

Google Map

以前はGoogleMapのジオコーディングのAPIがお手軽に使えましたが、昨今は、APIキー取得にはクレジットカード登録が必須になってしまい使用するための敷居が高くなっています。
https://developers.google.com/maps/documentation/geocoding/start?hl=ja

Yahoo!ジオコーダAPI 

1日5万回の実行まで使えます。
https://developer.yahoo.co.jp/webapi/map/openlocalplatform/v1/geocoder.html

実際に当方が使用したPythonでの実装例は以下のようになります。

https://github.com/mima3/yakusyopdf/blob/master/create_geo.py

特に注意するのはrecursiveパラメータで、もし、ぴったしの住所がなかった場合、上のレベルで検索します。
recursiveパラメータがtrueの場合、「東京都千代田区ほげ町」が存在しなければ「東京都千代田区」で検索した結果をかえしてくれます。recursiveパラメータがfalseの場合は上位レベルでの検索は行いません。

なお、座標データはXMLまたはJSONで取得可能です。

ジオコーダDAMS(Distributed Address Matching System)

いままではWebサービスでしたがDAMSはローカルにジオコーディング用の環境を作成できます。
http://newspat.csis.u-tokyo.ac.jp/geocode/modules/dams/index.php?content_id=1
APIの制限を気にせずにローカルの環境で実行できるのは大きな強みだと考えられます。

その他、Googleのジオコーダとの違いは以下に記載されています。
http://newspat.csis.u-tokyo.ac.jp/geocode/modules/smartfaq/category.php?categoryid=4

pydams

PythonからDAMSを使用できるライブラリです。
https://github.com/hottolink/pydams

ただしこのpythonのライブラリに指定しているパッチを当てると一部の住所の検索ができなくなりますので注意が必要です。(修正済み)
https://github.com/hottolink/pydams/issues/5

サクラの共有サーバーでPython3のWebサービスを動かす

目的

サクラの共有サーバーは2020/05時点でPythonが2.7です。
今回はPython3のWebサービスを動かす方法について紹介します。

【デモサイト】
https://needtec.sakura.ne.jp/yakusyopdf/

【コード】
https://github.com/mima3/yakusyopdf/tree/master/web

Python3のインストール

自分に権限のあるユーザディレクトリにPythonをソースからコンパイルしてインストールします。
今回の例では以下にインストールしました。

/home/ユーザ/local/bin/python3

2020/05時点ではPython3.8をサクラレンタルサーバーにビルドしてインストールする場合、以下のようなconfigureの実行後にmake installするだけでいいと思います。

./configure --prefix=$HOME/local/ 

※過去にはOpenSSLのバージョン違いのせいで、自前でOpenSSLもビルドする必要があったりした。

なお、過去に自前でPythonをインストールしていた場合は、自分でインストールした分は削除してから実行した方がいいです。

Webフレームワーク―Bottle

PythonのWebフレームワークは色々ありますが、今回のような環境ではBottleが最も楽です。

https://bottlepy.org/docs/dev/tutorial.html

Bottleの特徴として、bottle.pyという1ファイルをインポートするだけでルーティングの処理が記載できるようになります。

今回はbottleをcgiとして起動しています。
BottleをCGIとして起動するにはbottle.runを実行する際にパラメータにcgiを渡します。

from bottle import run
app = Bottle()
run(app, server='cgi')

サクラの共有サーバーでBottleを動かすには

さて、サクラの共有サーバーでBottleを動かすにはいくつかのコツがあります。

まず、cgiを動作させる際にpythonのパスを認識させる必要があります。
以下は実際のindex.cgiを抜粋したものです。

#!/home/needtec/local/bin/python3
# coding: utf-8
from bottle import run
# 略

一番先頭行にインストールしたpython3へのパスを記述します。
これにより、index.cgiを動作させる場合は、インストールしたpython3が動くようになります。

しかし、python3のパスだけを与えても動作はしません。
たとえば、pip3等でライブラリをインストールしていた場合などは、そのライブラリのパスもcgiが認識できるようにする必要があります。このためにはsys.pathにインポート対象のフォルダを追加する必要があります。

import sys
sys.path.append('/home/needtec/local/lib/python3.8/site-packages')
sys.path.append('/home/needtec/local/lib/python3.8/peewee-3.13.3-py3.8.egg-info')

また.htaccessを使用して「hoge/yakusyopdf/json/get_hospital」というリクエストを「hoge/yakusyopdf/index.cgi/json/get_hospital」に置き換えます。

.htaccess

RewriteEngine On

# Some hosts may require you to use the `RewriteBase` directive.
# If you need to use the `RewriteBase` directive, it should be the
# absolute physical path to the directory that contains this htaccess file.
#
# RewriteBase /

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.cgi/$1 [L]

RewriteCondeでリクエストしたファイルが存在しない場合は、index.cgiを前に付与するようなルールになっています。

ここまでやることで、サクラの共有サーバーでPython3を使用したBottleのWebアプリケーションが実行できます。

開発環境も考慮する

先までで挙げた方法は実機だけで開発する分には問題ないですが、ローカルの環境でも動かすには辛いものがあります。
たとえば、index.cgiに記載するPythonのパスやsys.pathに追加するディレクトリは環境ごとにことなります。環境を変えるたびにそれらを修正するのは、辛いです。

そこで今回は、index.cgiを自動で作成するという手法をとっています。

index.original.cgiを元にapplication.iniとpythonのパスを元にcreate_index_cgi.pyを使用してindex.cgiを作成します。

index.cgi.original

#![python_path]
# coding: utf-8
from bottle import run
# import ConfigParser
import configparser  #python3
import sys

conf = configparser.ConfigParser()
conf.read("[ini_path]")
try:
  i = 0
  path = conf.get('system', 'path' + str(i))
  while path != "":
    i = i + 1
    sys.path.append(path)
    path = conf.get('system', 'path' + str(i))
except configparser.NoOptionError as e:
  pass

from application import app, setup
setup(conf)
run(app, server='cgi')

これを元に作成したindex.cgiは以下の通りです。

#!/home/needtec/local/bin/python3
# coding: utf-8
from bottle import run
# import ConfigParser
import configparser  #python3
import sys

conf = configparser.ConfigParser()
conf.read("/home/needtec/work/yakusyopdf/application.ini")
try:
  i = 0
  path = conf.get('system', 'path' + str(i))
  while path != "":
    i = i + 1
    sys.path.append(path)
    path = conf.get('system', 'path' + str(i))
except configparser.NoOptionError as e:
  pass

from application import app, setup
setup(conf)
run(app, server='cgi')

[python_path]と[ini_path]がそれぞれ、Pythonのパスとapplication.iniへのパスに変換されています。また、application.iniに記載されたパス情報を基に、sys.pathに追加を行い、インクルード対象のフォルダを追加します。

あとはindex.cgiには処理を記載しなければ様々な環境でデプロイしやすい構造となります。(※今回はメインの処理はapplication.pyに記載している)

まとめ

今回はサクラの共有サーバーでPython3のWebサービスを動かす方法について述べました。
現時点ではPython3は自前でビルドしてインストールする必要があります。
また、CGIからそれらを使用する場合にはインストールしたパスを適切に設定してあげる必要があります。

sklearnを使用して文章の類似度を調べる

目的

TF-IDFによる2013年参議院選挙のパンフレットの解析で使用した文章の類似度を調べる処理をsklearnJanomeを使用して実行してみます。

実験コード

from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

def stems(doc):
    result = []
    t = Tokenizer()
    tokens = t.tokenize(doc)
    for token in tokens:
        result.append(token.surface.strip())
    return result

documents = [
    "私は猫と犬と神が好きだ",
    "私は猫と犬と神が好きではない",
    "私は神です",
    "私は戦争が好きだ",
    "ウンコティンティン!ウンコティンティン!",
]
vectorizer = TfidfVectorizer(analyzer=stems)
t = Tokenizer()
tfidf_matrix = vectorizer.fit_transform(documents)
for ix in range(len(documents)):
    print(documents[ix], '------------------------')
    similarities = cosine_similarity(tfidf_matrix[ix], tfidf_matrix)
    for similarity in similarities:
        for ix2 in range(len(similarity)):
            if ix2 == ix:
                continue
            print('  ', documents[ix2], similarity[ix2])

出力結果

私は猫と犬と神が好きだ ------------------------
   私は猫と犬と神が好きではない 0.8161301674485676
   私は神です 0.29355926474682525
   私は戦争が好きだ 0.47854281861265613
   ウンコティンティン!ウンコティンティン! 0.0
私は猫と犬と神が好きではない ------------------------
   私は猫と犬と神が好きだ 0.8161301674485676
   私は神です 0.32643300489571514
   私は戦争が好きだ 0.3486675556492787
   ウンコティンティン!ウンコティンティン! 0.0
私は神です ------------------------
   私は猫と犬と神が好きだ 0.29355926474682525
   私は猫と犬と神が好きではない 0.32643300489571514
   私は戦争が好きだ 0.24652369447564157
   ウンコティンティン!ウンコティンティン! 0.0
私は戦争が好きだ ------------------------
   私は猫と犬と神が好きだ 0.47854281861265613
   私は猫と犬と神が好きではない 0.3486675556492787
   私は神です 0.24652369447564157
   ウンコティンティン!ウンコティンティン! 0.0
ウンコティンティン!ウンコティンティン! ------------------------
   私は猫と犬と神が好きだ 0.0
   私は猫と犬と神が好きではない 0.0
   私は神です 0.0
   私は戦争が好きだ 0.0

tf-idfを使用した類似度の算出は単語の頻度によって類似度が決まります。
すなわち意味までは解析しないので「私は猫と犬と神が好きだ」と「私は猫と犬と神が好きではない」は類似度が高い文章として扱われます。

コードの説明

TfidfVectorizerはドキュメントのコレクションをTF-IDF機能のマトリックスに変換するクラスです。
インスタンスを作成する際に、文章を解析するための関数をanalyzerに渡します。
上記の例ではstems関数を渡しており、この処理の中でJanomeを使用して形態素解析を行い単語単位に分割した結果の一覧を返します。

TfidfVectorizer.fit_transformでは解析対象のドキュメントの一覧を与えてドキュメント用語マトリックスを返します。

その用語魔トリックを用いてcosine_similarityは類似度を計算します。

OCRで画像からテキストを抽出してみる

目的

PDFはテキストの情報が含まれている場合がありますが、場合によっては画像として文字を使用している場合があります。

今回はOCR機能を使用して画像からテキストを抽出してみます。
https://github.com/mima3/pdf_udon

PDFからの画像取得方法

PyMuPDFを使用すればPDFから画像を簡単に抽出できます。

import fitz
fitz.open('data/賛成0123.pdf')
doc = fitz.open('data/賛成0123.pdf')
pix = doc[0].getPixmap()
pix.writePNG('test.png')

Page.getPixmapメソッドを利用することで、Pixmapのオブジェクトを取得します。ピクセルの情報を保持しており、画像ファイルとして出力することも可能です。

これらの機能を使用してPDFからPNGを抽出するスクリプトが以下になります。
https://github.com/mima3/pdf_udon/blob/master/convert_image.py

OCRでテキストを取得する

今回はOCRでテキストを取得する方法で2種類ためしました。

  • Tesseract-OCR
  • Googleドキュメント

Tesseract-OCR

Tesseract-OCRはOpenSourceのOCRプログラムで、コマンドラインまたは、ダイナミックリンクのライブラリとして使用できます。
また、PyOCRを使用することでPythonから使用可能です。

詳しくは、GAMMASOFTの資料がわかりやすいです。

Tesseract OCR をWindowsにインストールする方法
https://gammasoft.jp/blog/tesseract-ocr-install-on-windows/

PythonでOCRを実行する方法
https://gammasoft.jp/blog/ocr-by-python/

単純なサンプル

以下が単純なサンプルになります。
この例ではPillowを使用してPNGファイルを読み取り、その内容をpyocrに渡しています。

import pyocr
import pyocr.builders
from PIL import Image

# tesseractをインストールしたフォルダにパスが通っていること
tools = pyocr.get_available_tools()
text=tools[0].image_to_string(
    Image.open('test.png'),
    lang='jpn',
    builder=pyocr.builders.TextBuilder()
)
print(text)

OpenCVを使用して画像に加工をする場合

もし、OpenCVで何らかの画像の処理をしてからpyocrに渡す場合は以下のようになります。

import pyocr
import pyocr.builders
import numpy as np
import cv2
from PIL import Image

def imread_jp(image_full_path):
    # cv2::imereadは日本語を含むパスを読めない
    # https://github.com/opencv/opencv/issues/4292
    # https://stackoverflow.com/questions/11552926/how-to-read-raw-png-from-an-array-in-python-opencv
    with open(image_full_path, 'rb') as img_stream:
        file_bytes = np.asarray(bytearray(img_stream.read()), dtype=np.uint8)
        img_data_ndarray = cv2.imdecode(file_bytes, cv2.IMREAD_UNCHANGED)
        return img_data_ndarray

# OpenCVを使用した何らかの処理
img = imread_jp('test.png')
img = cv2.fastNlMeansDenoisingColored(img,None,10,10,7,21)

# tesseractをインストールしたフォルダにパスが通っていること
tools = pyocr.get_available_tools()
text=tools[0].image_to_string(
    Image.fromarray(img),
    lang='jpn',
    builder=pyocr.builders.TextBuilder()
)
print(text)

下記の実装ではノイズの除去だけでなく角度の調整も行っています。
https://github.com/mima3/pdf_udon/blob/master/convert_text.py

テキスト座標の取得

TextBuilder()の代わりに、LineBoxBuilder(),WordBoxBuilder()を使用することで、行の位置や単語の位置も取得できます。

import pyocr
import pyocr.builders
from PIL import Image

# tesseractをインストールしたフォルダにパスが通っていること
tools = pyocr.get_available_tools()
line_boxs=tools[0].image_to_string(
    Image.open('test.png'),
    lang='jpn',
    builder=pyocr.builders.LineBoxBuilder()
)

for line_box in line_boxs:
    # 件 名 : バブ リッ クコ メン ト へ の 意見 ((68, 182), (235, 191))
    print(line_box.content, line_box.position)
    for word_box in line_box.word_boxes:
        print('  ', word_box.content, word_box.position)

出力例

件 名 : バブ リッ クコ メン ト へ の 意見 ((68, 182), (235, 191))
   件 ((68, 182), (87, 190))
   名 ((80, 172), (92, 200))
   : ((99, 172), (106, 200))
   バブ ((113, 182), (128, 191))
   リッ ((131, 182), (144, 190))
   クコ ((148, 183), (165, 191))
   メン ((168, 183), (186, 190))
   ト ((190, 182), (195, 190))
   へ ((197, 184), (204, 190))
   の ((207, 183), (215, 190))
   意見 ((217, 182), (235, 191))

GoogleドキュメントをOCRとして使う

Googleドライブにアップロードした画像をGoogleドキュメントとして開くことにより、画像に含まれている文字を認識してテキストに変換してくれます。

以下の実装はGoogleDriveのAPIを使用して画像をアップロードして、Googleドキュメントとして開いた内容のテキストを取得するサンプルになります。

https://github.com/mima3/pdf_udon/blob/master/convert_text_googledoc.py

このサンプルを実行するにはGoogle DriveにCSVをアップロードしてGoogleスプレッドシートとして編集後、ダウンロードするで紹介した前提条件を満す必要があります。

なお、Googleドキュメントに変換する場合、どうしてもローカルでtesseractを使用する場合より、速度的に劣ります。

OCR実行結果

今回紹介した方法で香川県のゲーム依存症対策パブリックコメントの画像からテキストを抽出した結果は以下の通りです。

https://github.com/mima3/pdf_udon/releases

ダウンロードすると各方式で取得したテキストを格納したSQLiteが取得できます。

Google DriveにCSVをアップロードしてGoogleスプレッドシートとして編集後、ダウンロードする

目的

今回はGoogle DriveniにCSVをアップロードしてGoogleスプレッドシートとして編集してみます。

まず、前提条件として以下のチュートリアルを実行してください。

Google Drive API- Python Quickstart
https://developers.google.com/drive/api/v3/quickstart/python

Google Sheets API- Python Quickstart
https://developers.google.com/sheets/api/quickstart/python

上記のチュートリアルで以下のことが行われると思います。

  • Google APIを使用するためのプロジェクトが作成される
  • 作成したプロジェクトにてGoogle Drive APIが有効になる。
  • 作成したプロジェクトにてGoogle SheetsのAPIが有効になる。
  • 認証用の情報を格納したJSONを取得している
  • Pythonのライブラリで下記をインストールしている。
    • google-api-python-client
    • google-auth-httplib2
    • google-auth-oauthlib

実験

Google DriveにCSVをアップロードする

コード

from __future__ import print_function
import sys
import pickle
import io
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from apiclient.http import MediaFileUpload
from googleapiclient.http import MediaIoBaseDownload

# スコープについては下記を参照してください.
# https://developers.google.com/drive/api/v3/about-auth
# 変更した場合は「token.pickle」を削除する必要があります.
SCOPES = [
    'https://www.googleapis.com/auth/drive'
]

def authenticate(client_secret_json_path):
    """QuickStartで行った認証処理と同じ認証処理を行う
    https://developers.google.com/drive/api/v3/quickstart/python
    """
    creds = None
    # ファイルtoken.pickleはユーザーのアクセストークンと更新トークンを格納し、
    # 認証フローが初めて完了すると自動的に作成されます。
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    # 有効な資格情報がない場合は、ユーザーにログインさせます。
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # ユーザーにブラウザーで認証URLを開くように指示し、ユーザーのURLを自動的に開こうとします。
            # ローカルWebサーバーを起動して、認証応答をリッスンします。
            # 認証が完了すると、認証サーバーはユーザーのブラウザーをローカルWebサーバーにリダイレクトします。
            # Webサーバーは、応答とシャットダウンから認証コードを取得します。その後、コードはトークンと交換されます
            flow = InstalledAppFlow.from_client_secrets_file(client_secret_json_path, SCOPES)
            creds = flow.run_local_server(port=0)
        # 次回実行のために「google.oauth2.credentials.Credentials」をシリアライズ化して保存します。
        # https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)
    return creds

def main(client_secret_json_path):
    """CSVのアップロードを行う"""
    creds = authenticate(client_secret_json_path)
    service_drive = build('drive', 'v3', credentials=creds)

    # CSVをGoogleスプレッドシートで編集できるようにUploadします.
    # https://developers.google.com/drive/api/v3/manage-uploads#python
    file_metadata = {
        'name': 'Test',
        'mimeType': 'application/vnd.google-apps.spreadsheet'
    }
    media = MediaFileUpload('test.csv',
                            mimetype='text/csv',
                            resumable=True)
    file = service_drive.files().create(body=file_metadata,
                                    media_body=media,
                                    fields='id').execute()
    print('File ID: %s' % file.get('id'))

if __name__ == '__main__':
    # 第1引数にQuickStartで作成したcredentials.jsonのパスを指定してください.
    main(sys.argv[1])

結果

コンソール
以下のように作成したファイルIDが出力されます

>python hello1.py client_secret.json
File ID: 1iJAdUMkXraufM3Wu0bsbzZvXZLxQQPNYaDvwd6nj97w

Google Drive
「Test」という名称のSheetsが追加されます。

スプレッドシートを開くと「Test」というシートにCSVの内容が表示されています。

Google DriveにアップロードしたCSVをGoogle Sheets APIで編集する

B列とC列を全て変更するサンプルです。
C列はGOOGLETRANSLATEを使用してB列の内容を日本語から英語に翻訳しています。

コード

from __future__ import print_function
import sys
import pickle
import io
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request

# スコープについては下記を参照してください.
# https://developers.google.com/drive/api/v3/about-auth
# 変更した場合は「token.pickle」を削除する必要があります.
SCOPES = [
    'https://www.googleapis.com/auth/drive'
]

def authenticate(client_secret_json_path):
    """QuickStartで行った認証処理と同じ認証処理を行う
    https://developers.google.com/drive/api/v3/quickstart/python
    """
    creds = None
    # ファイルtoken.pickleはユーザーのアクセストークンと更新トークンを格納し、
    # 認証フローが初めて完了すると自動的に作成されます。
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    # 有効な資格情報がない場合は、ユーザーにログインさせます。
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # ユーザーにブラウザーで認証URLを開くように指示し、ユーザーのURLを自動的に開こうとします。
            # ローカルWebサーバーを起動して、認証応答をリッスンします。
            # 認証が完了すると、認証サーバーはユーザーのブラウザーをローカルWebサーバーにリダイレクトします。
            # Webサーバーは、応答とシャットダウンから認証コードを取得します。その後、コードはトークンと交換されます
            flow = InstalledAppFlow.from_client_secrets_file(client_secret_json_path, SCOPES)
            creds = flow.run_local_server(port=0)
        # 次回実行のために「google.oauth2.credentials.Credentials」をシリアライズ化して保存します。
        # https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)
    return creds

def main(client_secret_json_path, file_id):
    """Googleスプレッドシートの更新を行う"""
    creds = authenticate(client_secret_json_path)
    service_sheets = build('sheets', 'v4', credentials=creds)
    result = service_sheets.spreadsheets().values().get(
        spreadsheetId=file_id,
        range="Test!A1:A").execute()
    print("行数", len(result.get('values')))

    # 全ての行のB列とC列を書き換える
    values = []
    for i in range(len(result.get('values'))):
        values.append(
            [
                # A列に文字を追加した内容をB列に埋め込む
                'こんにちわ ' + result.get('values')[i][0],
                # B列を日本語から英語に翻訳する数式をC列に埋め込む
                '=GOOGLETRANSLATE(indirect("RC[-1]", false),"ja","en")'
            ]
        )

    body = {
        'values': values
    }
    result = service_sheets.spreadsheets().values().update(
        spreadsheetId=file_id,
        range="Test!B1:C",
        valueInputOption='USER_ENTERED',
        body=body
    ).execute()
    print('{0} cells updated.'.format(result.get('updatedCells')))

if __name__ == '__main__':
    # 第1引数にQuickStartで作成したcredentials.jsonのパスを指定してください.
    # 第2引数に編集対象のGoogleスプレッドシートのFile IDを指定してください。
    # https://docs.google.com/spreadsheets/d/[ここの値を入力]/edit#gid=XXXXX
    main(sys.argv[1], sys.argv[2])

結果

すべての行のB列とC列が変更されていることが確認できます。
また、C列の数式も機能しています。

編集したCSVのダウンロード方法

指定のGoogle Drive中のファイルをローカルPCにダウンロードします。

コード

from __future__ import print_function
import sys
import pickle
import io
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.http import MediaIoBaseDownload

# スコープについては下記を参照してください.
# https://developers.google.com/drive/api/v3/about-auth
# 変更した場合は「token.pickle」を削除する必要があります.
SCOPES = [
    'https://www.googleapis.com/auth/drive'
]

def authenticate(client_secret_json_path):
    """QuickStartで行った認証処理と同じ認証処理を行う
    https://developers.google.com/drive/api/v3/quickstart/python
    """
    creds = None
    # ファイルtoken.pickleはユーザーのアクセストークンと更新トークンを格納し、
    # 認証フローが初めて完了すると自動的に作成されます。
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    # 有効な資格情報がない場合は、ユーザーにログインさせます。
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # ユーザーにブラウザーで認証URLを開くように指示し、ユーザーのURLを自動的に開こうとします。
            # ローカルWebサーバーを起動して、認証応答をリッスンします。
            # 認証が完了すると、認証サーバーはユーザーのブラウザーをローカルWebサーバーにリダイレクトします。
            # Webサーバーは、応答とシャットダウンから認証コードを取得します。その後、コードはトークンと交換されます
            flow = InstalledAppFlow.from_client_secrets_file(client_secret_json_path, SCOPES)
            creds = flow.run_local_server(port=0)
        # 次回実行のために「google.oauth2.credentials.Credentials」をシリアライズ化して保存します。
        # https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)
    return creds

def main(client_secret_json_path, file_id):
    """Googleスプレッドシートのダウンロードを行う"""
    creds = authenticate(client_secret_json_path)
    service_drive = build('drive', 'v3', credentials=creds)

    # download
    request = service_drive.files().export_media(fileId=file_id, mimeType='text/csv')
    fh = io.BytesIO()
    downloader = MediaIoBaseDownload(fh, request)
    done = False
    while done is False:
        status, done = downloader.next_chunk()
        print ("Download %d%%." % int(status.progress() * 100))
    with open('download.csv', 'wb') as f:
        f.write(fh.getvalue())

if __name__ == '__main__':
    # 第1引数にQuickStartで作成したcredentials.jsonのパスを指定してください.
    # 第2引数に編集対象のGoogleスプレッドシートのFile IDを指定してください。
    # https://docs.google.com/spreadsheets/d/[ここの値を入力]/edit#gid=XXXXX
    main(sys.argv[1], sys.argv[2])

注意

元ファイルの行数によってはダウンロード時に翻訳が完了していない場合があります。
もし翻訳が完了するまで待つ場合は以下のようにします。

https://github.com/mima3/pdf_translate/blob/master/translate_google_sheets.py#L45

PDFを翻訳したい

はじめに

PDFを翻訳する方法は色々あります。
ここでは色々の方法で実験してみます。

今回は以下のPDFを使用して[MS-CFB].pdfを対象にどのような翻訳がされるか確認してみましょう。

先駆者の方法

Google翻訳

Google翻訳でドキュメントタブからファイルをアップロードして翻訳が可能です。

お手軽に実行できますが、レイアウトが維持されない問題があります。
たとえば以下は MS-CFB.pdfの翻訳した結果の5ページ目にあたるのですが、図が削除されていることが確認できます。

WORDの利用

WORDの翻訳機能を使用する案です。
まず、PDFをWordで開いてWORD形式に保存します。
その後、「校閲タブ」の「翻訳」から翻訳を実行します。

残念ながら今回実験対象のPDFは下記のエラーが発生して翻訳が行えませんでした。

※なお、ページ数を減らすとできたので、ページ数の問題か、あるいは削除したページに含まれるデータの問題と考えられます。

DocTranslator

DocTranslatorというWebサービスが存在しており、PDFのレイアウトを維持したまま翻訳してくれます。
https://www.onlinedoctranslator.com/ja/

概ね、きれいに表示されるのですが、フォントの都合で対応できない場合もあります。

また、PDFのサイズによって翻訳に失敗するようで、PDF32000_2008.pdfは翻訳できませんでした。

Weblio英和辞典プラグイン

単語レベルでいいなら、下記のページに紹介されているAdobeReaderのWeblio英和辞典プラグインを使用する案もあります。

https://www.itmedia.co.jp/bizid/articles/1312/03/news034.html

別の方法を考える

先駆者の方法は多くの場合に十分ですが、PDF32000_2008.pdfで上手くいかなかったので別の方法を考えてみました。

pdf_translate
https://github.com/mima3/pdf_translate/blob/master/README.md

PDFに翻訳した結果の注釈を埋め込むことでレイアウトと原文を維持しつつ翻訳情報を差し込むことができます。

以下がPDF32000_2008.pdf
にたいして実際、注釈を付与したPDFになります。

http://needtec.sakura.ne.jp/doc/tmp/output.pdf

※ブラウザで見ると注釈が文字化けするのでダウンロードしてスマホ以外のAdobeReaderで閲覧してみてください。

メリット

  • 大き目のPDFの翻訳に対応できる。
  • 中間ファイルとして翻訳用のCSVを作成するので、気に入らなければ人手で修正することもできる。

デメリット

  • 手間がかかる。
    • Pythonを使うのはともかく、機械翻訳を作る部分の手作業が多い。
  • Adobe Acrobat Readerで閲覧しないと注釈が正常に表示されない
    • Chromeで見ると注釈ば文字化けする
    • スマホで見ると注釈がポップアップしない
  • サイズが増える。
  • こんなメンドクサイことしなくても良い方法が他にありそうに思えて仕方がない。

PDFの点線を実線におきかえる(PyMuPDF)

はじめに

下記の記事でPyPDF2とreportlabを用いてPDFの点線を実線に置き換える実験を行いました。

この時、PyPDF2の制限で保存したPDFのサイズが増加しました。

今回は別のライブラリを用いて、同じ処理を行います。

PyMuPDF

PyMuPDFはPythonを使用してMuPDFを操作します。
MuPDFは軽量の軽量のPDF、XPS、および電子書籍ビューアです。
ピュアなPythonで記載されたPyPDF2と異なりパフォーマンスの向上が期待できます。
パフォーマンスの詳細は下記を参照してください。
https://pymupdf.readthedocs.io/en/latest/app1/#

簡単な例

以下のサンプルはPDFの内容をSVGに出力後、左上に四角を描画します。
入力に使用しているPDFは以下のものを使用しています。
https://github.com/atlanhq/camelot/files/3565115/Test.pdf

import fitz

doc = fitz.open('test.pdf')
print(doc.pageCount)
page = doc.loadPage(0)
print('ページの領域:', page.rect)
# SVGとして出力
with open('tmp1.svg', 'w') as fp:
    fp.write(page.getSVGimage())

# 左上に四角を描画
rect = fitz.Rect(0, 10, 300, 40)
page.drawRect(rect, color=(1,0,0), width=1)

#doc.saveIncr()   # update file. Save to new instead by doc.save("new.pdf",...)
doc.save("new.pdf")

出力されたSVGをChromeで表示した結果は以下の通りとなります。

点線が削除されています。
また、残念なことにPyPDF2で行ったようにオペコードを直接取得はできないようなので、正確に座標情報を取得したい場合はPyPDF2を用いる必要があるでしょう。

次に、図形を描画したPDFを見てみます。

PyPDF2+reportlabで使用した場合とPyMuPDFで座標が異なることに注意してください。
前者は(0、0)を左下としていますが、MuPDFはページの左上隅を(0、0)としています。これについては下記のIssueで言及されています。

Question: is PyMuPDF rect compatible with reportlab rect? #348
https://github.com/pymupdf/PyMuPDF/issues/348

点線の実線にする

ではPDFの点線を実線におきかえる(PyPDF2 + reportlab)で行ったのと同じように処理を行います。

線情報の取得方法は同じですが、ファイルの書き込みをPyMuPDFで行います。

対象のPDFは以下になります。
https://github.com/mima3/yakusyopdf/blob/master/20200502/%E5%85%B5%E5%BA%AB%E7%9C%8C.pdf

import fitz
import PyPDF2
from PyPDF2.pdf import ContentStream
from PyPDF2.utils import b_

def get_rectangle(content):
    result = []
    for operands, operator in content.operations:
        if operator == b_('re'):
            result.append(fitz.Rect(operands[0], operands[1], operands[0]+ operands[2], operands[1] + operands[3]))
    return result

def read_pdf(path):
    rects = []
    with open(path, "rb") as input_stream:
        input_pdf = PyPDF2.PdfFileReader(input_stream)
        for page in range(input_pdf.numPages):
            print('read', page + 1, '/', input_pdf.numPages)
            page_obj = input_pdf.getPage(page)
            content = page_obj['/Contents'].getObject()
            if not isinstance(content, ContentStream):
                content = ContentStream(content, input_pdf)
            rects.append(get_rectangle(content))

    return rects

path = "test/兵庫県.pdf"
rects = read_pdf(path)
doc = fitz.open(path)
ix = 0
for page in doc:
    print('write', ix + 1, '/', len(doc))
    for rc in rects[ix]:
        # https://github.com/pymupdf/PyMuPDF/issues/348
        new_rc = rc * page._getTransformation()
        page.drawRect(new_rc, color=(1,0,0), width=1)
    ix += 1
# https://pymupdf.readthedocs.io/en/latest/tutorial/#saving
doc.save("new.pdf",  garbage=4, clean=1, deflate=1)  # or doc.saveIncr() 

PyMuPDFでは保存時に圧縮の指定や余分なオブジェクトの削除が行えるので、ファイルサイズを抑えることが可能になっています。

まとめ

PyMuPDFを用いることでファイルサイズを抑制して図形を描画することが可能であることが確認できました。

今回はPyPDF2で線の座標を取得していますが、完全な線でなくていいなら、SVGから線の座標情報を取得することも可能であると思います。