PDFの点線を実線におきかえる(PyPDF2 + reportlab)

はじめに

以下の記事でcamelotを使用してPDFからテーブルを抽出する場合に、PDFが点線で構成されているとテーブルを旨く認識できない問題を上げました。
camelotで点線を実線として処理する

この時はcamelotが保持する中間の画像データに対してOpenCVを使用して点線を実線に書き換えるアプローチをとりました。

今回はPDFに含まれている線情報を、そもそも書き換えることができるかを検証してみます。

PDFのコンテンツの情報を取得する

camelotをインストールする際に同時にインストールされるPyPDF2を使用して解析してみます。

PyPDF2にはextractTextというPDF中からテキスト情報を抽出する処理がありますが、これを参考にすることでPDF中の線情報を取得できると考えられます。

以下のコードはextractTextを参考に記載したものでPDFからコンテンツ情報を列挙するものになります。

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

pdf = PyPDF2.PdfFileReader(open("Test.pdf", "rb"))
page = pdf.getPage(0)

content = page['/Contents'].getObject()
if not isinstance(content, ContentStream):
    content = ContentStream(content, pdf)

for operands, operator in content.operations:
    print(operator, operands)

PDFはoperatorとoperandsの組み合わせでテキストや画像を描画しており、その詳細の仕様は下記にあります。

https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf

この資料の「A.2 PDF Content Stream Operators」を元にoperatorがどんな動きをしているか解析することが可能になります。

camelotで点線を実線として処理するにて実験に使用したPDFファイルは以下のoeratorが使用されていることがわかります。

b're' [116.3, 596.38, 374.11, 1.92]

これの意味は以下のようになります。

Operator Operands Description 解説ページ
re x y width height Append rectangle to path 133

これで四角形の座標と大きさを認識することができます。

PDFのコンテンツの情報を更新する

PDFの図形情報を書き換えるのは、簡単にいきそうもありませんでした。
しかしながら、reportlabを使用することで図形を追加することはできそうです。

具体的な手順は以下のようになります。

  • 入力のPDFを開いて図形の座標を取得する。
  • reportlabを使用してキャンバスに取得した図形と同じ箇所に、同じ図形を描画して新しいPDFのページを作る
  • 入力につかったページと新規のページをマージして出力する。

これを実装したものが以下のようになります。

import PyPDF2
from PyPDF2 import PdfFileWriter, PdfFileReader
from io import BytesIO
from reportlab.pdfgen import canvas
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(operands)
    return result

with open('output.pdf', 'wb') as output_stream, open("Test.pdf", "rb") as input_stream:
    output = PdfFileWriter()

    # read your existing PDF
    input_pdf = PyPDF2.PdfFileReader(input_stream)
    for page in range(input_pdf.numPages):
        print(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)
        rectangles = get_rectangle(content)
        #
        buffer = BytesIO()
        c = canvas.Canvas(buffer, pagesize=(page_obj.mediaBox.getWidth(), page_obj.mediaBox.getHeight()))
        c.setStrokeColorRGB(255, 0, 0)
        for rect in rectangles:
            c.rect(rect[0], rect[1], rect[2], rect[3], fill=0)
        c.showPage()
        c.save()
        buffer.seek(0)
        ### DEBUG
        #pdf_tmp = buffer.getvalue()
        #open('tmp{}.pdf'.format(page), 'wb').write(pdf_tmp)
        ###
        new_pdf = PdfFileReader(buffer)
        page_obj.mergePage(new_pdf.getPage(0))
        output.addPage(page_obj)
        output.write(output_stream)

変換結果:

画像のリンク

Excelの罫線のないところも線を描画していますが、camelotで認識するという目的では使用できると思います。

問題点

このアプローチでは大きな問題点が2点あります。
まず、ファイルサイズが激増します。3MBのPDFが440MBになりました。
また、ファイルの増加と関係すると思いますが使用メモリも大量になります。700MBほど使用していました。

この問題はIssueとしてあがっていますが、解決はしていません。

StackOverflowでもファイルサイズの問題は質問されていますが、別ツールを使用しろという結果になっています。

オープンソースのcpdfsqueezeを使用することでファイルのサイズの問題は解決できますが、処理中のメモリの問題は依然として残る結果となります。

下記のページでは代替のライブラリであるPyMuPdf libraryが紹介されています。

まとめ

PDF中の点線を実線に書き換えるというアプローチは、PDF上のreオペレータを見ることで場所を特定して、線を上書きするという方法で可能です。
この場合、Excelの罫線のないところも線が引かれてしまうので、これを回避するには別のoperatorも観る必要があります。

また、PyPDF2を使用した場合、圧縮がサポートされていないためファイルサイズが増加し、また、処理中のメモリも大きなものとなりまします。
ファイルサイズの増加については外部のツールで圧縮することが可能ですが、メモリの増加についてはPyPDF2上で回避する方法は現時点でなさそうです。

camelotで点線を実線として処理する

はじめに

camelotではパラメータの調整だけでは点線を含むテーブルの処理が上手く動作しません。

たとえば、以下のようなPDFがそれにあたります。
➀縦の点線
https://github.com/atlanhq/camelot/files/3565115/Test.pdf

②横の点線
https://github.com/mima3/yakusyopdf/blob/master/20200502/%E5%85%B5%E5%BA%AB%E7%9C%8C.pdf

この問題は下記のIssueとして挙げられていますが、解決されていないようです。

Detect dotted line #370
https://github.com/atlanhq/camelot/issues/370

対応方法

Camelotのテーブル認識の仕組み

対応方法の説明の前にCamelotがどのようにテーブルを認識しているか説明します。
Camelotにはテーブルの解析の方法に2種類存在します。1つはStreamでもう1つはLatticeです。
デフォルトではLatticeで解析しています。

Latticeではhostscriptを使用してPDFページを画像に変換し、次に、OpenCVを使用して線とその交点を求めます。
そしてそれらの座標を元に、テーブルのセルに割り当てていきます。

対応方法

Latticeでは一旦、画像に変換しているわけですから、その画像において点線を実線に置き換えればよいです。

縦の点線を実線に変換する場合は、縦方向にdilateとerodeを行います。
横の点線を実線に変換する場合は、横方向にdilateとerodeを行います。
こうやって変更した画像をcamelotの線分を見つける処理に渡せばいいのです。

具体的な実装例

まずLatticeをベースクラスとしたLatticeExを用意します。
https://github.com/mima3/yakusyopdf/blob/master/camelot_ex.py

これを以下のように使用します

import camelot_ex
import camelot
import os
import cv2
import numpy

from camelot.utils import (
    TemporaryDirectory,
)

def image_proc_tate_tensen(threshold):
    el = numpy.zeros((5,5), numpy.uint8)
    el[:, 1] =1
    threshold = cv2.dilate(threshold, el, iterations=1)
    threshold = cv2.erode(threshold, el, iterations=1)
    return threshold

def image_proc_yoko_tensen(threshold):
    el = numpy.zeros((5,5), numpy.uint8)
    el[2, :] =1
    threshold = cv2.dilate(threshold, el, iterations=1)
    threshold = cv2.erode(threshold, el, iterations=1)
    return threshold

def parse(filepath, pages, password=None, suppress_stdout=False, layout_kwargs={}, **kwargs):
    handler = camelot.handlers.PDFHandler(filepath)
    handler_pages = handler._get_pages(filepath, pages)

    tables = []
    with TemporaryDirectory() as tempdir:
        for p in handler_pages:
            handler._save_page(filepath, p, tempdir)
        tmp_pages = [
            os.path.join(tempdir, "page-{0}.pdf".format(p)) for p in handler.pages
        ]
        parser = camelot_ex.LatticeEx(
            **kwargs
        )
        for p in tmp_pages:
            t = parser.extract_tables(
                p,
                suppress_stdout=suppress_stdout,
                layout_kwargs=layout_kwargs
            )
            tables.extend(t)

        return tables

# 縦の点線を含むPDF
ret = parse(
     'test.pdf', 
     'all', 
     line_scale=60,
     image_proc = image_proc_tate_tensen
)
for ix in ret[0].df.index.values:
    s = ''
    for col in ret[0].df.columns:
        s = s + ',' + ret[0].df.loc[ix][col]
    print(ix, s)

# 横の点線を含むPDF
ret = parse(
     '20200502/兵庫県.pdf', 
     '1', 
     layout_kwargs={
         'char_margin': 0.25
     },
     line_scale=60,
     copy_text=['v'],
     image_proc = image_proc_yoko_tensen
)
for ix in ret[0].df.index.values:
    s = ''
    for col in ret[0].df.columns:
        s = s + ',' + ret[0].df.loc[ix][col]
    print(ix, s)

参考

厚生労働省のPDFをCSVやJSONに変換する

目的

政府がオープンデータを叫び出して何年かが過ぎましたが、多くの政府が公開するデータはPDFベースになっています。
さすがにひと昔前のように紙をスキャンしただけのデータではなくなりましたがCSVやJSONなどの機械的に処理を行うのが楽であるとは言い難い現状です。

今回は下記のページに公開されているPDFを処理しやすい形式(CSV,JSON)に変換する実験を行います。
 

新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療について

https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/rinsyo/index_00014.html

このページのPDFは以下の性質をもっています。

  • 文字情報をもっている。(画像ではない)
  • 日々更新される可能性がある
  • 県によってはフォーマットが異なる可能性がある

実験環境

  • Windows10 64bit
  • Python 3.7.5 (tags/v3.7.5:5c02a39a0b, Oct 15 2019, 00:11:34) [MSC v.1916 64 bit (AMD64)]
  • Java8
  • Office365

PDFからテーブルを取得する方法

まずPDFからデータを取得する方法を考えてみます。
前述した実験環境においてPDFからテーブルの情報を取得するには3つの方法があります。

  • PDFをWORDに変換してからテーブルを取得する
  • tabulaを使用する
  • camelotを使用する

PDFをWORDに変換してからテーブルを取得する

PDFをWORDで開いたのち、WORDのテーブルをExcelに張り付けることで処理しやすい形式に変換することが可能です。

この方法は、お手軽で少ない少ない件数のPDFを処理する場合において有効ですが、失敗するケースがあります。

たとえば、下記のPDFをダウンロードしてWORDで開いてみます。
https://www.mhlw.go.jp/content/000625002.pdf

下記のようなエラーが発生します。

このPDFを開くときに問題が発生しました。Wordによってサポートされている最大ページ数を超えています。

このエラーメッセージはPDFの用紙サイズがWord文書の最大用紙サイズである558.7mm×558.7mmを超えている場合に発生します。

tabulaを使用する

tabula-javaはJavaで実装されており、PDFからテーブルの情報を抜き出すことが可能です。また、tabula-pyを使用することでPythonからtabula-javaを使用することが可能になります。

GUIでtabulaを使用する。

まずJava7以降の実行環境を構築してください。

次に下記のページから環境にあったファイルをダウンロードしてください。今回はWindowsで実行します。
https://tabula.technology/

解凍したexeを起動することでwebサーバーが起動して、ブラウザ経由でPDFの解析処理が可能になります。

ただし、exeを普通に起動するだけでは以下のようなエラーが発生します。

(ArgumentError) invalid byte sequence in Windows-31J

そのため以下のようなバッチファイルを記述して、バッチファイル経由で起動する必要があります。

set RUBYOPT=-EUTF-8
tabula.exe

あとは、ブラウザを経由してPDFをアップロードし、PDF中のテーブルのエクスポートが可能になります。

参考:
http://deathon2legs.blogspot.com/2018/08/tabulapdf.html

Pythonでtabulaを使用する

tabula-pyを使用することでPythonでtabulaが使用できます。

tabula-pyを使用するにはPython3.5+,Java 8+の環境が必要になります。

インストール方法や必要要件は下記を参照してください。
https://tabula-py.readthedocs.io/en/latest/getting_started.html

実装例

下記のサンプルはPDFからテーブルを取得して施設名の一覧を出力するものになります。

import tabula

# 当該ページは日々更新されるため、下記のリンクから最新のURLを取得してください
# https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/rinsyo/index_00014.html
df_list = tabula.read_pdf("https://www.mhlw.go.jp/content/000626082.pdf", pages='all', lattice=True)
for df in df_list:
    for ix in df.index.values:
        print(df.loc[ix][1])

制限

PDFの複雑さによっては、表の内容の正確さを抽出することが難しい場合があります。すくなくとも新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてで公開されているPDFのいくつは正確に抽出することはできませんでした。

camelotを使用する

camelotはPDFの表をより正確に抽出可能になっています。

camelotはghostscriptを使用しているのでWindowsの場合、以下からインストールを行う必要があります。
https://www.ghostscript.com/download/gsdnld.html

依存関係の詳細は以下を参照してください。
https://camelot-py.readthedocs.io/en/master/user/install-deps.html

インストール方法については下記を参照してください。今回はsource codeを使用してインストールしました。
https://camelot-py.readthedocs.io/en/master/user/install.html

なお、camelotはtabula-pyにくらべメモリを大量に使用する傾向があるので64bitのプロセスで実行することをお勧めします。すくなくとも東方の32bit環境では愛知県のデータは取得できませんでした。

実装例

下記のサンプルはPDFからテーブルを取得して施設名の一覧を出力するものになります。

import camelot
tables = camelot.read_pdf(
  'https://www.mhlw.go.jp/content/000626082.pdf',
  pages = 'all',      # ページによってはメモリ使用量が跳ね上がるので1ページづつ解析した方がよい
  line_scale = 30,   # 数が大きいほど細い線でも認識するようになるが、大きすぎると文字を線とみなす デフォルト15
  copy_text=['v'],      # 空のデータを取得した場合に縦方向からデータをコピーする。表を結合している場合に有効
  layout_kwargs = {
    'char_margin': 0.25 # 2つの文字がこのマージンよりも接近している場合、それらは同じ行の一部と見なされます。 デフォルト:2
  }
)
for tbl in tables:
  for ix in tbl.df.index.values:
    print(tbl.df.loc[ix][1])

各方法の比較

PDFからテーブルを抽出する方法として以下の3つを比較します。

  • PDFをWORDに変換してからテーブルを取得する
  • tabulaを使用する
  • camelotを使用する

PDFをWORDに変換してからテーブルを取得する方法は、お手軽に行えるメリットがありますが、今回使用するには問題があります。
まず、当該PDFは日々更新されています。そのため手動で行う処理が辛いです。仮にこの問題をマクロ等でカバーできたとしても、WORDの最大用紙サイズの制限があるため、完全に今回のデータを処理するには適さない方法です。

tabulaとcamelotについては精度の話を抜きにした場合、今回のPDFを機械的に読み取ることが可能です。
処理速度と使用メモリについてはtabulaが有利です。
逆に精度についてはcamelotが有利です。
camelotはいくつかのパラメータでチューニングが可能になっています。

主なパラメータの説明
https://camelot-py.readthedocs.io/en/master/api.html#main-interface

camelotはPDFMinerの機能を元に作成されており、PDFMinerで調整できる項目についてはlayout_kwargsのパラメータで指定できます。この項目についてはPDFMinerのドキュメントで確認できます。

https://euske.github.io/pdfminer/

(2020/05/03追記)
Camelotでは点線を含むPDFの処理が上手く動作しません。この対応については以下の通りになります。
https://needtec.sakura.ne.jp/wod07672/2020/05/03/camelot%e3%81%a7%e7%82%b9%e7%b7%9a%e3%82%92%e5%ae%9f%e7%b7%9a%e3%81%a8%e3%81%97%e3%81%a6%e5%87%a6%e7%90%86%e3%81%99%e3%82%8b/

「新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療について」のページを解析する

ここまででPDFからテーブル情報を取得する基本的な方法について説明しました。
しかしながら、実際、抽出するにはいくつか問題点があります。

問題点

対応医療機関リストのPDFは日々更新され、そのURLも変わる

対応医療機関リストのPDFは日々更新されますが、そのURLは更新のたびに変更されるようです。
例えば東京都のURLは以下のようになります。

2020/04/28時点
https://www.mhlw.go.jp/content/000625693.pdf

2020/04/29時点
https://www.mhlw.go.jp/content/000626106.pdf

このため、PDFのURLは新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてのリンクから取得する必要があります。

凡例の有無

データの1行目に凡例が入っている場合とない場合があります。
東京都の場合は凡例がありますが、北海道の場合はありません。

つまり県ごとに1ページ目のデータ行の取得位置を調整する必要があります。

各県ごとにページヘッダの取り扱いが異なる。

たとえば、北海道と茨城県のPDFを比較してみてください。
北海道は2ページ目以降にヘッダが含まれていますが、茨城県には含まれません。

つまり県ごとに2ページ目以降のデータ行の取得位置を調整する必要があります。

各県ごとに項目の取り扱いが異なる。

たとえば、北海道と愛知県を比較してみてください。
愛知県には所管保健所の列がありますが、北海道には存在しません。

つまり県ごとに必要な列の位置を調整する必要があります。

複数ページにまたがるデータ

以下は2020/04/28時点の奈良県のPDFです。
https://github.com/mima3/yakusyopdf/blob/master/20200428100754/%E5%A5%88%E8%89%AF%E7%9C%8C.pdf

ここで2ページと3ページ目にまたがっているデータが存在することが分かります。
施設名などは3ページ目にあります。

以下は同日の鳥取県のPDFです。
https://github.com/mima3/yakusyopdf/blob/master/20200428100754/%E9%B3%A5%E5%8F%96%E7%9C%8C.pdf

ここで6ページと7ページ目にまたがっているデータが存在することが分かります。
施設名などは6ページ目にあります。

つまり、施設名などが空の行を発見した場合に、改ページによって分割されている可能性を考慮する必要があります。

長い文字を含むデータ

たとえば以下のようにセルからはみ出るようなデータが存在する場合、結合された情報として抽出される可能性があります。

これを完全に回避する方法はなく、郵便番号の形式や電話番号の形式に当たらない場合は結合されているとみなして分割するなどの次善策を行う必要があります。

問題点を踏まえた上での処理

以下のようになります。
https://github.com/mima3/yakusyopdf

使用方法

# 新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてのページからPDFをダウンロードしてフォルダに保存する
python download_pdf.py

# 作成したフォルダを指定して、PDFの内容からJSONとCSVを作成する
python analyze_pdf.py 20200428100754

結果
https://github.com/mima3/yakusyopdf/tree/master/20200428100754

まとめ

ある程度まではPDFからCSVやJSONなどに変換することはできました。

しかしながら、完璧に行うことは不可能です。
たとえば、県ごとに細かい設定とかを行えるようにはしましたが、これはあくまで現時点のものであり、PDFの書き方が急に変ったりします。

このため官公庁のPDFを解析する場合は、解析した時点の日付を明示する必要があり、また、フォーマットが変わった場合の検知が肝要と思われます。

はてなブックマークのREST APIを利用する

目的

はてなブックマークをREST API経由で登録してみます。
環境は以下の通りです。
・windows 10
・python 3.7.4

はてなブックマークのREST APIについては下記を参照してください。
http://developer.hatena.ne.jp/ja/documents/bookmark/apis/rest#authorization

事前準備

pythonを使用してはてなブックマークをREST API経由で登録するにはOAuth認証をする必要があります。
ここではOAuth認証を行うための事前準備について説明します。

Consumer Keyの取得

(1)Consumer Keyを取得するため、OAuth 開発者向け設定ページにアクセスします。この際、はてなのユーザーIDに紐づくパスワードの入力が要求されます。

(2)「新しいアプリケーション」の作成ボタンを押します。

(3)下記を参考に必要な項目を変更し、Consumer KeyとConsumer Secretをメモしておきます。

・アプリケーションの名称、説明、URLを変更します。スクリプトで使用するのでURLは127.0.0.1になっています。

・Consumer KeyとConsumer Secretをメモします。この値は外部に漏れないようにしましょう。

・必要な権限を割り当てます。はてなブックマークを登録するだけなら「write_public」が必要になります。

・「変更する」ボタンを押下します。
・「提供アプリケーション一覧に戻る」をクリックすると、新しいアプリケーションが追加されています。
※どうも削除する方法はないようで、使わなくなったら「無効化」するようです。

requests-oauthlibのインストール

OAuth認証を自前で実装するのは辛いのでrequests-oauthlibをインストールします。
https://github.com/requests/requests-oauthlib

はてなブックマークの登録

はてなブックマークを登録するためのサンプルコード

https://github.com/mima3/qiita_exporter/blob/master/hatena_api.py

"""はてなREST APIを操作する.

はてなのRESTAPIについては下記を参照してください。
http://developer.hatena.com/ja/documents/bookmark/apis/rest

また、OAuthの認証方法については下記のページを参考にしています.

"""

import requests
# https://github.com/requests/requests-oauthlib
from requests_oauthlib import OAuth1Session

class HatenaApiError(Exception):
    """HatenaApiErrorのエラーが発生したことを知らせる例外クラス"""

class HatenaApi:
    """はてなAPIを操作するクラス."""
    LOGIN_URL = 'https://www.hatena.ne.jp/login'
    session = None

    def _get_rk(self, hatena_id, password):
        """はてなIDとログインパスワードからrkを取得します。
        以下のコードを利用しています
        
        https://github.com/iruca/hatena-oauth-python/blob/master/get_access_token_util.py

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
        Returns:
            rk文字列
        Raises:
            HatenaApiError: rk文字列が取得できなかったとき。ID/パスワードが間違っているか、rkを取得するためのはてなAPIの仕様が変わった
        """
        payload = {'name': hatena_id, 'password': password}
        response = requests.post(self.LOGIN_URL, data=payload)
        if not "Set-Cookie" in response.headers:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        if not "rk=" in response.headers['Set-Cookie']:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        rk = response.headers["Set-Cookie"].split("rk=")[1].split(";")[0]
        return rk

    def _get_authorization_redirect_url(self, user_id, password, authorization_url):
        """authorization_urlにアクセスしてアプリケーションの許可を行う."""
        rk = self._get_rk(user_id, password)
        res_auth = requests.get(authorization_url, headers={"Cookie" : "rk="+ rk}).text
        rkm = res_auth.split("<input type=\"hidden\" name=\"rkm\" value=\"")[1].split("\"")[0]
        oauth_token = res_auth.split("<input type=\"hidden\" name=\"oauth_token\" value=\"")[1].split("\"")[0]
        res_redirect = requests.post(
            authorization_url,
            headers={"Cookie": "rk="+ rk},
            params={
                "rkm": rkm,
                "oauth_token": oauth_token,
                "name": "%E8%A8%B1%E5%8F%AF%E3%81%99%E3%82%8B"
            }
        )
        return res_redirect.url

    def authorize(self, user_id, password, consumer_key, consumer_secret, scope):
        """OAuth認証を行う.

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
            consumer_key: 「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key
            consumer_secret:「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret
            scope : 取得権限. ex "read_public,write_public"
        """
        self.session = OAuth1Session(
            consumer_key,
            consumer_secret,
            callback_uri="http://localhost/hogehoge"  # このURLは実際にアクセスできる必要はありません.
        )
        self.session.fetch_request_token(
            "https://www.hatena.com/oauth/initiate?scope={}".format(scope)
        )
        authorization_url = self.session.authorization_url("https://www.hatena.ne.jp/oauth/authorize")
        redirect_response = self._get_authorization_redirect_url(
            user_id,
            password,
            authorization_url
        )
        self.session.parse_authorization_response(redirect_response)
        self.session.fetch_access_token("https://www.hatena.com/oauth/token")

    def add_bookmark(self, url, comment="", tags=None):
        """はてなブックマークの追加または更新を行う.
        このメソッドを実行する前に、authorizeを呼び出す必要がある.

        Args:
            url:  ブックマーク対象のURL
            comment: コメント
            tags: タグの一覧
        """
        if not self.session:
            raise HatenaApiError("call authorize method.")
        if tags:
            for t in tags:
                comment += "[{}]".format(t)
        return self.session.post(
            "https://bookmark.hatenaapis.com/rest/1/my/bookmark?url=",
            params={
                "url": url,
                "comment" : comment,
            }
        )

上記のクラスは以下のように使用します。

    api = HatenaApi()
    api.authorize("はてなのユーザ名", "パスワード", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret", "write_public")
    res = api.add_bookmark("http://developer.hatena.com/ja/documents", "test!", [])
    res.raise_for_status()
    print(res.text)

解説

OAuth認証を行ったあとに,はてなブックマーク用のAPIを実行するだけですが、WebアプリケーションではなくスクリプトだけでOAuth認証を行うのは結構面倒くさいです。

理由はOAuth認証の途中で以下のような画面を表示してユーザが該当のアプリケーションを許可するプロセスが入るためです。

この問題は以下のページにて解決策が書かれています。

はてなのOAuth API用のアクセストークンを簡単に取得する [python]
https://www.iruca21.com/entry/2017/05/24/090000

簡単にいうと、ユーザーが画面で許可するボタンを押したのと同じ動きを行っています。先のコードでいうと_get_authorization_redirect_urlが、その動きに当たります。

また、python3xで動くようにするのと、できるだけ処理をrequests_oauthlibで行うように直してあります。

JavaScriptでテキストの差分を見るライブラリ

まえがき

JavaScriptで2ファイルのテキストの差分を確認するためのDiff用ライブラリについて調べます。

difflib

image.png

GitHub:
https://github.com/cemerick/jsdifflib

デモサイト
http://cemerick.github.io/jsdifflib/demo.html

サンプルコード

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
<link rel="stylesheet" href="diffview.css">
<script src="difflib.js"></script>
<script src="diffview.js"></script>
<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>
<!-- End Matomo Code -->
    </head>
    <body>
        <div id="output"></div>
        <script  type="text/javascript">
          const src1 = document.getElementById('src1').innerText;
          const src2 = document.getElementById('src2').innerText;

          var base = difflib.stringAsLines(src1);
          var newtxt = difflib.stringAsLines(src2);

          // create a SequenceMatcher instance that diffs the two sets of lines
          var sm = new difflib.SequenceMatcher(base, newtxt);

          // get the opcodes from the SequenceMatcher instance
          // opcodes is a list of 3-tuples describing what changes should be made to the base text
          // in order to yield the new text
          var opcodes = sm.get_opcodes();
          var contextSize = 0;
          document.getElementById('output').append(diffview.buildView({
              baseTextLines: base,
              newTextLines: newtxt,
              opcodes: opcodes,
              // set the display titles for each resource
              baseTextName: "Base Text",
              newTextName: "New Text",
              contextSize: contextSize,
              viewType: 1 // 0にするとbaseとnewTextが別の列になります
          }));

        </script>
    </body>
</html>

使用感

・シンプルで使い易いですが、細かい調整はできないようです。(たとえば文字レベルでの差分の表示は現時点でできない)
・積極的な開発はおこなわれていないようです。
・BSDライセンスです。

prettydiff

image.png

GitHub
https://github.com/prettydiff/prettydiff/

デモ
https://prettydiff.com/

ドキュメント
https://prettydiff.com/documentation.xhtml

サンプル

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
<script src="prettydiff/js/browser.js"></script>
<link href="prettydiff/css/index.css" media="all" rel="stylesheet" type="text/css"/>

<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>
<!-- End Matomo Code -->
    </head>
    <body>
        <div class="white" id="prettydiff"></div>
        <script  type="text/javascript">
          const src1 = document.getElementById('src1').innerText;
          const src2 = document.getElementById('src2').innerText;

// integrate into the browser
let output     = "",
    prettydiff = window.prettydiff,
    options    = window.prettydiff.options;
options.source_label = "修正前";
options.source = src1;
options.diff_label = "修正後";
options.diff = src2;
options.diff_format = "html";

options.mode = "diff";
options.language = "auto";
options.lexer = "text";
options.wrap = 10;
options.diff_view = 'inline'; // 'sidebyside'で別の列で表示
output         = prettydiff();
console.log(output);
          document.getElementById('prettydiff').innerHTML = output;
// You can include the Pretty Diff code in any way that is convenient,
// whether that is using an HTML script tag or concatenating the
// js/browser.js code with your other code.

        </script>
    </body>
</html>

使用感

・ブラウザで使用するbrowser.jsはnpmでインストール後、tscコマンドで作成されます
さまざまなオプションがあります
・ライセンスはCC0

mergely

image.png

GitHub
https://github.com/wickedest/Mergely

デモ
http://www.mergely.com/

サンプル

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>DIFF</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/codemirror.min.js"></script>
<link rel="stylesheet" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/codemirror.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/addon/search/searchcursor.min.js"></script>
<script src="mergely/libs/mergely.js" type="text/javascript"></script>
<link rel="stylesheet" media="all" href="mergely/libs/mergely.css" />
    </head>

<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>

<body>
<div class="mergely-full-screen-8">
  <div class="mergely-resizer">
    <div id="mergely"></div>
  </div>
</div>

        <script>
$(document).ready(function () {
    $('#mergely').mergely({
        wrap_lines : true,
        cmsettings: { 
            readOnly: false,
            lineNumbers: true
        },
        lhs: function(setValue) {
            setValue(document.getElementById('src1').innerText);
        },
        rhs: function(setValue) {
            setValue(document.getElementById('src2').innerText);
        }
    });
});
        </script>
</body>

    </body>
</html>

使用感

・依存ライブラリが多い(JQuery,codemirror)
・codemirrorがエディタ用のライブラリなので、修正したテキストの差分がすぐ確認できる
・ライセンスはGNU LGPL v3.0

まとめ

・差分を表示するだけならprettydiffがよさそうです。
・マージなどの編集が必要ならmergelyになるでしょうが、JQueryに依存しています。
・2ファイルを入力とするのでなく、diffの結果を入力としてHTML表示するならdiff2htmlも使えそうです。
・なお、画像の差分をとるならjs-imagediffが使えそうでした(未検証)

参考

JavaScript based diff utility [closed]
https://stackoverflow.com/questions/3053587/javascript-based-diff-utility

Qiitaの記事をGitHubに移行する

はじめに

一つのサービスに依存するのは、リスクだと思うのでQiitaの記事をGitHubに移行するスクリプトを書いてみます。

なお、私の記事は以下のようになりました。
https://github.com/mima3/note

動作環境

Python 3.7.4
Windows10

事前準備

Qiitaのアクセストークンを取得します。

(1)設定画面のアプリケーションタブから「新しいトークンを発行する」を押下します。

image.png

(2)読み取り権限を付けて「発行」を押下します
image.png

(3)アクセストークンをメモしておきます
image.png

使用方法

(1)以下のリポジトリからスクリプトを取得する
https://github.com/mima3/qiita_exporter

(2)下記の形式でスクリプトを実行する。

python qiita_to_github.py [userid] [accesstoken] [保存先フォルダ] [GitHubのブロブのルートURL ex.https://github.com/mima3/note/blob/master]

(3)保存先フォルダをGitHubに登録します。

やっていること

・QiitaAPIを利用してQiita上の指定ユーザが記載した全てのマークダウンを取得します。
・画像をローカルにダウロードして、リンクを書き換えます。
・コードブロック以外の行にて、改行コードの前にスペース2ついれて改行を行います。
・「#タイトル」という記述があったら「# タイトル」に直します。
・コードブロックのタイトル(例:「```python:test.py」)が表示されないので対応します。
・自分の記事へのURLを移行先のURLに修正する

課題

・タグとかの表現をどうするか。
・コメントとかの取り扱いをどうするか。
・タイトルをファイル名としているが、使っている文字によっては動かない。タイトルによっては大文字に変換とかの小細工が必要になると思う。
・もしかするとWikiの機能を利用した方がいいかも。。。
・Qiitaで表示できてたTwitterの埋め込みは旨く表示されない。
image.png

image.png
GitHub PagesでJekyllが上手く動作しないマークダウンがある。たとえば{%s}とかいうコードが混ざっていると失敗する。
image.png

image.png

 

Vue.js+ bulmaでダイアログを表示してみる

目的

JavaScriptのフレームワークであるVue.jsとCSSのフレームワークである bulmaを使用してダイアログを表示してみる。

image.png

サンプル

Vue.jsにbulmadialog-componentコンポーネントを追加します。
その後、任意のタイミングで親側からコンポーネントのメソッドshowDialogを実行します。
この際、以下のプロパティを持つオブジェクトをパラメータに指定します。

  • title : ダイアログのタイトル
  • contents : ダイアログの内容。HTMLタグは無効
  • html : ダイアログの内容。HTMLのタグは有効
  • buttons : ボタン情報の配列
    • caption : ボタンのタイトル
    • callback : コールバック関数

コンポーネント側

IE11で動くようにしています。

bulma_dialog.js

/**
 * bulma + vue.jsでダイアログを表示します。
 * html: vue.jsの管理下に以下を追加します
 *   <bulmadialog-component ref="dialog"></bulmadialog-component>
 * js:Vue.jsを作成するときにコンポーネントを追加する
 *  components: {
      'bulmadialog-component': BulmaDialog,
    },        
 * js:Vue.jsの親のメソッドにて以下を実行
 *  this.$refs.dialog.showDialog({
        title:'わっふるる',
        //contents:'わっふるぼでぃ0\nsadfasfd',
        html : 'あたえたt<br>awrawtあたえたt<br>',
        buttons : [
          {
            caption : 'はい',
            callback : function () {
              console.log('はい');
            }
          },
          {
            caption : 'いいえ',
            callback : function () {
              console.log('いいえ');
            }
          }
        ]
      });
 */
// eslint-disable-next-line no-unused-vars
const BulmaDialog = {
  /* eslint-disable max-len */
  template: (function() {/*
      <div v-bind:class="{ 'is-active': isShow }" class="modal">
        <div class="modal-background"></div>
          <div class="modal-card">
              <header class="modal-card-head">
                  <p class="modal-card-title">{{data.title}}</p>
              </header>
              <div >
              </div>
              <section v-if="data.html" v-html="data.html" class="modal-card-body"></section>
              <section v-else class="modal-card-body">{{data.contents}}</section>
              <footer class="modal-card-foot"  style="justify-content: flex-end;">
                  <button v-for="btnObj in data.buttons" type="button" class="button" @click="btnObj.callback(); isShow = false;">{{btnObj.caption}}</button>
              </footer>
          </div>
      </div>
      </div>
    */}).toString().match(/\/\*([^]*)\*\//)[1],
  /* eslint-enable */
  data: function() {
    return {
      isShow: false,
      data: {
        title: '',
        body: '',
        html: '',
        buttons: [],
      },
    };
  },
  methods: {
    showDialog: function(data) {
      this.isShow = true;
      this.data.title = data.title;
      this.data.contents = data.contents;
      this.data.html = data.html;
      this.data.buttons = data.buttons;
    },
  },
};

使う側

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Hello Bulma!</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="/phptest/codeview/js/bulma_dialog.js"></script>
  </head>
  <body>
  <div id="app">
    <bulmadialog-component ref="dialog"></bulmadialog-component>
    <button class="button is-primary" @click="test1">ダイアログA</button>
    <button class="button is-primary" @click="test2">ダイアログB</button>
  </div>
<script>
var app = new Vue({
  el: '#app',
  components: { //Scopedが使える
    'bulmadialog-component': BulmaDialog,
  },
  data: function() {
      return {
      }
  },
  methods: {
    test1 : function () {
      this.$refs.dialog.showDialog({
        title:'確認(TEXT)', 
        contents: "ふんぐるい むぐるうなふ くとぅるう るるいえ うがふなぐる ふたぐん<br>Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn",
        buttons : [
          {
            caption : 'はい',
            callback : function () {
              console.log('test1 はい');
            }
          },
          {
            caption : 'いいえ',
            callback : function () {
              console.log('test2 いいえ');
            }
          }
        ]
      });
    },
    test2 : function () {
      this.$refs.dialog.showDialog({
        title:'確認(HTML)', 
        html: "ふんぐるい むぐるうなふ くとぅるう るるいえ うがふなぐる ふたぐん<br>Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn",
        buttons : [
          {
            caption : 'YES',
            callback : function () {
              console.log('Yes');
            }
          },
          {
            caption : 'No',
            callback : function () {
              console.log('No');
            }
          }
        ]
      });
    }
  }
})
</script>

  </body>
</html>

参考

SafariでもエラーにならないJavascriptのヒアドキュメントの書き方
https://qiita.com/ampersand/items/c6c773ba7ae9115856d0

メモ

Bulma自体がIEのサポートを部分的にのみしかしていないので、テンプレートリテラルを使ってIEをあきらめた方が無難かも

Bulma uses autoprefixer to make (most) Flexbox features compatible with earlier browser versions. According to Can I use, Bulma is compatible with recent versions of:

  • Chrome
  • Edge
  • Firefox
  • Opera
  • Safari

Internet Explorer (10+) is only partially supported.

https://github.com/jgthms/bulma

Twig + bulma でページネーションをやってみる

目的

PHPのテンプレートフレームワークであるTwigとCSSのフレームワークであるBulmaでページネーションをやってみる
image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

コード

PHPでテンプレートを使用する際に下記のデータを指定してください。

  • currentPage: 現在のページ番号
  • maxPage : 最大ページ番号
  • pageRange : ここで指定したページ数分、最初のページ, 最終ページ, 現在ページの前後ページへのリンクを省略せずに表示する。

PHP側

        return $this->view->render(
            $response,
            'commitlog.twig',
            [
                'BASE_PATH' => $this->config['BASE_PATH'],
                'commitlogs' => $commitlogs,
                'pageLimit' => $limit,
                'currentPage' => $page,
                'maxPage' => $maxPage,
                'pageRange' => 2
            ]
        );

ページネーション用のテンプレート

pagination.php

<nav class="pagination is-centered" role="navigation" aria-label="pagination">
    {% if currentPage != 1 %}
        <a class="pagination-previous" href="?page={{currentPage - 1}}">Previous</a>
    {% endif %}
    {% if currentPage != maxPage %}
        <a class="pagination-next" href="?page={{currentPage + 1}}">Next page</a>
    {% endif %}
    <ul class="pagination-list">
        {% set preItemHasEllipsis = false %}
        {% for i in range(1,maxPage) %}
            {% if i == currentPage %}
                <li><a class="pagination-link is-current" aria-lasbel="Goto page {{i}}">{{i}}</a></li>
                {% set preItemHasEllipsis = false %}
            {% elseif (i <= 1 + pageRange) or 
                      (i >= maxPage - pageRange) or
                      ((currentPage - pageRange <= i) and (i <= currentPage + pageRange))
            %}
                <li><a class="pagination-link" aria-lasbel="Goto page {{i}}" href="?page={{i}}">{{i}}</a></li>
                {% set preItemHasEllipsis = false %}
            {% elseif preItemHasEllipsis == false and ( 
                        (i == 1 + 1 + pageRange) or 
                        (i == maxPage - pageRange - 1) or
                        (i == currentPage - pageRange - 1) or
                        (i == currentPage + pageRange + 1)
                    )
            %}
                <li><span class="pagination-ellipsis">…</span></li>
                {% set preItemHasEllipsis = true %}
            {% endif %}
        {% endfor %}                
    </ul>
</nav>

読み出し側のテンプレート

// 略
        <h1 class="title">コミットログ</h1>
        <div class="content">
            <div class="table-container">
             // 略
            </div>

            {{ include("component/pagination.twig")}}
// 略

メモ

ループは効率が悪そうなので、速度が重要なら分岐でうまいこと作った方がいいと思う。

Githubでスターが多いWYSIWYGエディタ(2019年11月)

はじめに

2019/11時点でGithubにあるWYSIWYGタグでスターが多いライブラリを調べてみます。

Quill

概要

Github
https://github.com/quilljs/quill

Demo
https://quilljs.com/
image.png

・テーブルの作成はできないようです(ver2.x用にテーブル追加のプラグインはある)
・クリップボードを経由して画像のアップロードが可能です。
highlight.jsを使用してコードブロックのハイライトが可能のようです

対象ブラウザ
image.png
IEは非推奨のようです。

ライセンス
BSD 3-clause

サンプル

1.3.7のサンプル

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <!-- highlight.js を使う場合はquillの前に参照する-->
    <link rel="stylesheet"
          href="http://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.16.2/build/styles/default.min.css">
    <script src="http://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.16.2/build/highlight.min.js"></script>

    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

  </head>
  <body>

<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>
<button id="btnContent">コンテンツ取得</button>
<button id="btnImage">イメージの挿入</button>
<button id="btnDisable">編集可能/不可能</button>

<script>
var Delta = Quill.import('delta');
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    syntax : true,              // Include syntax module
    // https://quilljs.com/docs/modules/toolbar/
    toolbar : [
      ['bold', 'italic', 'underline', 'strike'],
      [{ 'color': [] }, { 'background': [] }], 
      ['link', 'image'] ,
      ['code-block']
    ]
  }
});

document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});

document.getElementById('btnImage').addEventListener('click', function() {
  // このあたりを工夫すればクリップボードからの画像貼り付け等ができそう・・
  console.log(quill.getSelection(true).index);
  quill.insertEmbed(quill.getSelection(true).index, 'image', 'https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png');
});

let enableEditor = false;
document.getElementById('btnDisable').addEventListener('click', function() {
  quill.enable(enableEditor);
  enableEditor = !enableEditor;
  console.log(enableEditor);
});

/**
 * ペーストのイベント追加例
 */
quill.root.addEventListener("paste", function (t) {
  console.log('paste');
  console.log(t);
  return true;
} , false);

</script>
  </body>
</html>

拡張モジュール

画像の貼り付けについて

quill-image-drop-and-paste
https://github.com/chenjuneking/quill-image-drop-and-paste

image.png

quill-image-drop-and-pasteはどうも以下のように修正しないと動作しないようです。
export.ImageDropAndPasteを使用しているが、設定していないので替わりにImageDropAndPasteを設定する。

(function(){var exports={};
"use strict";Object.defineProperty(exports,"__esModule",{value:true});var _createClass=function(){function e(e,t){for(var a=0;a<t.length;a++){var n=t[a];n.enumerable=n.enumerable||false;n.configurable=true;if("value"in n)n.writable=true;Object.defineProperty(e,n.key,n)}}return function(t,a,n){if(a)e(t.prototype,a);if(n)e(t,n);return t}}();function _classCallCheck(e,t){if(!(e instanceof t)){throw new TypeError("Cannot call a class as a function")}}var ImageDropAndPaste=function(){function e(t){var a=arguments.length>1&&arguments[1]!==undefined?arguments[1]:{};_classCallCheck(this,e);this.quill=t;this.options=a;this.handleDrop=this.handleDrop.bind(this);this.handlePaste=this.handlePaste.bind(this);this.quill.root.addEventListener("drop",this.handleDrop,false);this.quill.root.addEventListener("paste",this.handlePaste,false)}_createClass(e,[{key:"handleDrop",value:function e(t){var a=this;t.preventDefault();if(t.dataTransfer&&t.dataTransfer.files&&t.dataTransfer.files.length){if(document.caretRangeFromPoint){var n=document.getSelection();var i=document.caretRangeFromPoint(t.clientX,t.clientY);if(n&&i){n.setBaseAndExtent(i.startContainer,i.startOffset,i.startContainer,i.startOffset)}}this.readFiles(t.dataTransfer.files,function(e,t){if(typeof a.options.handler==="function"){a.options.handler(e,t)}else{a.insert.call(a,e,t)}},t)}}},{key:"handlePaste",value:function e(t){var a=this;if(t.clipboardData&&t.clipboardData.items&&t.clipboardData.items.length){this.readFiles(t.clipboardData.items,function(e,t){if(typeof a.options.handler==="function"){a.options.handler(e,t)}else{a.insert(e,t)}},t)}}},{key:"readFiles",value:function e(t,a,n){[].forEach.call(t,function(e){var t=e.type;if(!t.match(/^image\/(gif|jpe?g|a?png|svg|webp|bmp)/i))return;n.preventDefault();var i=new FileReader;i.onload=function(e){a(e.target.result,t)};var r=e.getAsFile?e.getAsFile():e;if(r instanceof Blob)i.readAsDataURL(r)})}},{key:"insert",value:function e(t,a){var n=(this.quill.getSelection()||{}).index||this.quill.getLength();this.quill.insertEmbed(n,"image",t,"user")}}]);return e}();exports.default=ImageDropAndPaste;
window.Quill.register('modules/imageDropAndPaste',ImageDropAndPaste)})(); // export.ImageDropAndPaste->ImageDropAndPaste
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

    <script src="quill-image-drop-and-paste-master/quill-image-drop-and-paste.min.js" type="text/javascript"></script>

  </head>
  <body>
<button id="btnContent">コンテンツ取得</button>
<br>
クリップボードからイメージをbase64で張り付けている。
<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>

<script>

const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    imageDropAndPaste : true
  }
});
document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});
</script>
  </body>
</html>

テーブル操作

quilljs-table

quilljs-table
https://github.com/dost/quilljs-table

image.png

サンプルを見る限り、テーブルの削除や列、行の削除がGUIからできそうにないです。
最終コミット日が2017年。

quilljs-table

quilljs-table
https://github.com/volser/quill-table-ui

quilljs v2.0.0-dev.3が必要になります。
最終更新日は2019年10月25日です。

テーブルの操作は以下のようなイメージになります。
image.png

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.0-dev.3/quill.min.js" type="text/javascript"></script>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.0-dev.3/quill.snow.min.css" rel="stylesheet">

    <script src="https://unpkg.com/quill-table-ui@1.0.5/dist/umd/index.js" type="text/javascript"></script>
    <link href="https://unpkg.com/quill-table-ui@1.0.5/dist/index.css" rel="stylesheet">

  </head>
  <body>
<button id="btnContent">コンテンツ取得</button>
<button id="btnTable">テーブル追加</button>
<br>
クリップボードからイメージをbase64で張り付けている。
<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>

<script>

Quill.register({
  'modules/tableUI': quillTableUI.default
}, true);

const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    table: true,
    tableUI: true,
  }
});
document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});
document.getElementById('btnTable').addEventListener('click', function() {
  let table = quill.getModule('table');
  console.log(table);
  table.insertTable(3, 3);
});

</script>
  </body>
</html>

メモ

2019/11/28時点の最終リリースはバージョン1.3.7です。
2.0の開発が進められていますが、そのマイルストーンは不透明なものとなっています。
https://github.com/quilljs/quill/issues/2435

moduleを実装することで拡張機能が作れる模様。

trix

Github
https://github.com/basecamp/trix

Demo
https://trix-editor.org/
image.png

対象ブラウザ
IE 11以降をサポートしているようです。
https://github.com/basecamp/trix/issues/173

ライセンス
MIT License

サンプル

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Trix</title>
    <link rel="stylesheet" type="text/css" href="trix.css">
    <script type="text/javascript" src="trix.js"></script>
  </head>
  <body>

<!-- Create the editor container -->
<trix-editor class="trix-content">サンプル</trix-editor>

<button id="btnContent">コンテンツ取得</button>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>

<script>
document.getElementById('btnContent').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  console.log(element.editor.getDocument());
  console.log(element.editor.getDocument().toString());
});

// エディタの内容はJSON化して保存と読み込みが可能
document.getElementById('btnSave').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  localStorage["editorState"] = JSON.stringify(element.editor);
});

document.getElementById('btnLoad').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  element.editor.loadJSON(JSON.parse(localStorage["editorState"]));
});

// イベントの確認
addEventListener("trix-attachment-add", function(event) {
  // 添付ファイルや画像を追加するとこのイベントが実行される
  // 以下のコードを参考にfileuploadとかができそう
  // https://trix-editor.org/js/attachments.js
  console.log('trix-attachment-add');
  console.log(event.attachment);
});
addEventListener("trix-attachment-remove", function(event) {
  // 添付ファイルや画像を削除するとこのイベントが実行される
  console.log('trix-attachment-remove');
  console.log(event.attachment);
});

addEventListener("trix-change", function(event) {
  // 内容が変化した場合実行
  console.log('trix-change');
  console.log(event);
});

</script>
  </body>
</html>

メモ

学習コストは低いと思われる。
※最低限の動作確認はtrix-editorタグを作ってtrix.jsを読み込むだけでいい。

テーブルをサポートする予定はない。
https://github.com/basecamp/trix/issues/539

コードブロックはあるが強調表示はサポートしていない。
image.png

拡張とかはできなさそう。

MediumEditor

medium.comインラインエディターツールバーのクローン

Github
https://github.com/yabwe/medium-editor

Demo
http : //yabwe.github.io/medium-editor/
image.png

画像の貼り付けやコードブロックはなさそう。

対象ブラウザ
image.png
IEをサポートしている

ドキュメント
https://github.com/yabwe/medium-editor/wiki

ライセンス
MIT

サンプル

単純な例

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Trix</title>
    <script src="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/js/medium-editor.min.js"></script>
    <link rel="stylesheet" href="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/css/medium-editor.min.css" type="text/css" media="screen" charset="utf-8">
  </head>
  <body>

<div class="editable"></div>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>
<script>
var editor = new MediumEditor('.editable', {
    placeholder: {
        text: 'テキストを入力してください',
        hideOnClick: true
    },
    toolbar: {
        /* These are the default options for the toolbar,
           if nothing is passed this is what is used */
        allowMultiParagraphSelection: true,
        buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'],
        diffLeft: 0,
        diffTop: -10,
        firstButtonClass: 'medium-editor-button-first',
        lastButtonClass: 'medium-editor-button-last',
        relativeContainer: null,
        standardizeSelectionStart: false,
        static: false,
        /* options which only apply when static is true */
        align: 'center',
        sticky: false,
        updateOnEmptySelection: false
    }
});

document.getElementById('btnSave').addEventListener('click', function() {
  console.log(editor.getContent());
  localStorage["medium"] = editor.getContent();
});
document.getElementById('btnLoad').addEventListener('click', function() {
  console.log(editor.getContent());
  editor.setContent(localStorage["medium"]);
});
</script>
  </body>
</html>

MediumEditor Tables

テーブルの作成を行うプラグインです。
Jqueryに依存しています。

GitHub
https://github.com/yabwe/medium-editor-tables

demo
https://yabwe.github.io/medium-editor-tables/

tablemedium.gif

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Medium</title>
    <link rel="stylesheet" href="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/css/medium-editor.min.css" type="text/css" media="screen" charset="utf-8">

    <!-- medium-editor-tables.js が使用している -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>

    <script src="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/js/medium-editor.min.js"></script>
    <script type="text/javascript" src="lib/js/medium-editor-tables.js"></script>

    <link rel="stylesheet" href="lib/css/medium-editor-tables.css" />

  </head>
  <body>

    <div class="editable"></div>

<script>
  var editor = new MediumEditor('.editable', {
    toolbar: {
      buttons: [
        'bold',
        'italic',
        'table'
      ]
    },
    extensions: {
      table: new MediumEditorTable()
    }
  });
</script>
  </body>
</html>

jQuery insert plugin for MediumEditor

画像やYoutubeやTwitterなどの埋め込みが可能なプラグインです。
Jqueryに依存します。

Github
https://github.com/orthes/medium-editor-insert-plugin

demo
https://linkesch.com/medium-editor-insert-plugin/

tablemedium3.gif

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Medium</title>
    <link href="http://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/css/medium-editor-insert-plugin-frontend.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/css/medium-editor-insert-plugin.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor/5.23.3/css/medium-editor.min.css" />

    <!-- medium-editor-tables.js が使用している -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.12/handlebars.runtime.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-sortable/0.9.13/jquery-sortable-min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/jquery.ui.widget@1.10.3/jquery.ui.widget.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.iframe-transport/1.0.1/jquery.iframe-transport.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-file-upload/9.28.0/js/jquery.fileupload.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/medium-editor/5.23.3/js/medium-editor.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/js/medium-editor-insert-plugin.min.js"></script>

  </head>
  <body>

    <div class="editable"></div>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>
<script>
  var editor = new MediumEditor('.editable', {
    toolbar: {
      buttons: [
        'bold',
        'italic',
        'table'
      ]
    }
  });
  $('.editable').mediumInsert({
      editor: editor
  });
document.getElementById('btnSave').addEventListener('click', function() {
  console.log(editor.getContent());
  localStorage["medium"] = editor.getContent();
});
document.getElementById('btnLoad').addEventListener('click', function() {
  console.log(editor.getContent());
  editor.setContent(localStorage["medium"]);
});
</script>
  </body>
</html>

メモ

Medium Editorのライブラリ自体はJavaScriptのみで外部のライブラリに依存していない。
しかし、その拡張機能がJQueryに依存している。

任意の拡張機能が作成可能。
https://github.com/yabwe/medium-editor/blob/master/src/js/extensions/README.md

Pell

もっともサイズの小さいWYSIWYGライブラリで他のライブラリに依存しません。

GitHub
https://github.com/jaredreich/pell

Demo
https://jaredreich.com/pell/
画像はURL指定して表示。
Link等でダイアログを表示する際はブラウザのメッセージボックスを使用している

image.png

対象ブラウザ
image.png
かなり古いブラウザでも動作するようです。

ライセンス
MIT

メモ

軽量であるのが売り。
テーブル機能はなさそう。
また拡張機能等はなさそう。

Editor.js

GitHub
https://github.com/codex-team/editor.js

Demo
https://editorjs.io/

image.png

・表、画像のアップロードをサポートしている。
・ツールバーは表示されずに、必要な時にポップアップが出る

対象ブラウザ
image.png

IEは対象外の模様
※すくなくともデモサイトはIE11で動作しない

ドキュメント
https://github.com/codex-team/editor.js/tree/bcdfcdadbc444921aee62b38516329cda3c96a70/docs

ライセンス
Apache License 2.0
寄付を受け付けている
https://opencollective.com/editorjs

サンプル

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Editor.js example</title>
  <link href="https://fonts.googleapis.com/css?family=PT+Mono" rel="stylesheet">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
</head>
<body>
  <div class="ce-example">
    <div class="ce-example__content _ce-example__content--small">
      <div id="editorjs"></div>

      <button id="saveButton">
        editor.save()
      </button>
      <button id="loadButton">
        editor.load()
      </button>
    </div>
  </div>

  <!-- Load Tools -->
  <!--
   You can upload Tools to your project's directory and use as in example below.
   Also you can load each Tool from CDN or use NPM/Yarn packages.
   Read more in Tool's README file. For example:
   https://github.com/editor-js/header#installation
   -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/header@latest"></script><!-- Header -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/simple-image@latest"></script><!-- Image -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@latest"></script><!-- Delimiter -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/list@latest"></script><!-- List -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/checklist@latest"></script><!-- Checklist -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@latest"></script><!-- Quote -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/code@latest"></script><!-- Code -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@latest"></script><!-- Embed -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/table@latest"></script><!-- Table -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/link@latest"></script><!-- Link -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@latest"></script><!-- Warning -->

  <script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@latest"></script><!-- Marker -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@latest"></script><!-- Inline Code -->

  <!-- Load Editor.js's Core -->
  <script src="./dist/editor.js"></script>

  <!-- Initialization -->
  <script>
    /**
     * To initialize the Editor, create a new instance with configuration object
     * @see docs/installation.md for mode details
     */
    var initObj = {
      /**
       * Wrapper of Editor
       */
      holder: 'editorjs',
      /**
       * Tools list
       */
      tools: {
        /**
         * Each Tool is a Plugin. Pass them via 'class' option with necessary settings {@link docs/tools.md}
         */
        header: {
          class: Header,
          inlineToolbar: ['link'],
          config: {
            placeholder: 'Header'
          },
          shortcut: 'CMD+SHIFT+H'
        },
        /**
         * Or pass class directly without any configuration
         */
        image: {
          class: SimpleImage,
          inlineToolbar: ['link'],
        },
        list: {
          class: List,
          inlineToolbar: true,
          shortcut: 'CMD+SHIFT+L'
        },
        checklist: {
          class: Checklist,
          inlineToolbar: true,
        },
        quote: {
          class: Quote,
          inlineToolbar: true,
          config: {
            quotePlaceholder: 'Enter a quote',
            captionPlaceholder: 'Quote\'s author',
          },
          shortcut: 'CMD+SHIFT+O'
        },
        warning: Warning,
        marker: {
          class:  Marker,
          shortcut: 'CMD+SHIFT+M'
        },
        code: {
          class:  CodeTool,
          shortcut: 'CMD+SHIFT+C'
        },
        delimiter: Delimiter,
        inlineCode: {
          class: InlineCode,
          shortcut: 'CMD+SHIFT+C'
        },
        linkTool: LinkTool,
        embed: Embed,
        table: {
          class: Table,
          inlineToolbar: true,
          shortcut: 'CMD+ALT+T'
        },
      },
      /**
       * This Tool will be used as default
       */
      // initialBlock: 'paragraph',
      /**
       * Initial Editor data
       */
      data: {
      },
      onReady: function(){
      },
      onChange: function() {
        console.log('something changed');
      }
    };
    var editor = new EditorJS(initObj);
    /**
     * Saving example
     */
    const saveButton = document.getElementById('saveButton');
    const loadButton = document.getElementById('loadButton');

    saveButton.addEventListener('click', function () {
      editor.save().then((savedData) => {
        console.log(savedData);
        localStorage["editJs"] = JSON.stringify(savedData);
      });
    });
    loadButton.addEventListener('click', function () {
      let data = JSON.parse(localStorage["editJs"]);
      console.log(data);
      editor.render(data);
    });
  </script>
</body>
</html>

メモ

IEは動作しない
undo機能は2019/11/28時点では自前でやる必要があるっぽい。
https://github.com/codex-team/editor.js/issues/518

CKEditor5

Gitのスター順で並べると上位5位にでてきませんが、バージョンごとにプロジェクトが分かれているっぽいので累計すると、結構使われているように見えます。
公式ページを見ると累計で27.500.000+のダウンロードが行われているそうです。

GitHub
https://github.com/ckeditor/ckeditor5

Demo
https://ckeditor.com/ckeditor-5/demo/

ライセンス
GNU General Public License Version 2 or later.

商用ライセンスがある。
https://ckeditor.com/pricing/#null

メモ

この中ではデモが一番、使い易かったので、金が豊富にあるならコレがよさそう。

まとめ

色々調べましたが、結局、一長一短ある感じがします。
その上で個人的にはコードのハイライトが簡単に使えそうなQuillか、学習コストの低そうなtrixがよさそうに見えます。

復活のVisualStudioのマクロ

はじめに

VisualStudio2008あたりまではVisualStudioにマクロがついていました。
この機能は最近のVisualStudioからは削除されましたが、再び拡張機能として復活しています。

https://github.com/microsoft/VS-Macros

VisualStudio2019でもそれっぽく動作するようです。

導入手順

(1)拡張機能の管理より「Macros for Visual Studio」をインストールします
image.png

(2)インストール後、再起動をするとツールからMacrosメニューが使用できるようになります。
image.png

(3)Macro Exploreを選択すると、Macro Exploreウィンドウが表示されます。
image.png

(4)JSファイルを右クリックして開くを選択することでJSの中身が編集可能になります。
image.png

なお、JSファイルをダブルクリックすることで記載したJSを実行可能です。

サンプル

出力ウィンドウに文字列を出力する方法

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
// https://github.com/Microsoft/VS-Macros/issues/28
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();
outputWindowPane.OutputString("display this text in the output window panel\n");

image.png

出力ウィンドウのクリアを行う

dte.ExecuteCommand("Edit.ClearAll");

ソリューションのプロジェクトのアイテムをすべて列挙する方法

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();

function dumpProjectItems(projectItems, lv) {
    for (var i = 1; i <= projectItems.Count; i++) {
        var file = projectItems.Item(i);
        for (var j = 0; j < lv; ++j) {
            outputWindowPane.OutputString(' ');
        }
        outputWindowPane.OutputString(file + "\n");

        if (file.SubProject != null) {
            dumpProjectItems(file.ProjectItems, lv + 1);
        } else if (file.ProjectItems != null && file.ProjectItems.Count > 0) {
            dumpProjectItems(file.ProjectItems, lv + 1);
        }
    }
}

for (var i = 1; i <= dte.Solution.Projects.Count; i++) {
    outputWindowPane.OutputString(dte.Solution.Projects.Item(i).Name + "\n");
    dumpProjectItems(dte.Solution.Projects.Item(i).ProjectItems, 1);
}

関数の先頭に移動してその関数の情報を取得

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();

var textSelection = dte.ActiveDocument.Selection;

// Define Visual Studio constants
var vsCMElementFunction = 2;
var vsCMPartHeader = 4;

var codeElement = textSelection.ActivePoint.CodeElement(vsCMElementFunction);

if (codeElement != null) {
    // 関数の先頭に移動
    textSelection.MoveToPoint(codeElement.GetStartPoint(vsCMPartHeader));
    dte.ActiveDocument.Activate();

    outputWindowPane.OutputString(codeElement.FullName + "\n");
    outputWindowPane.OutputString(codeElement.GetStartPoint(vsCMPartHeader).Line + "\n");
    outputWindowPane.OutputString(codeElement.GetEndPoint().Line + "\n");
    outputWindowPane.OutputString(codeElement.FullName + "\n");
    outputWindowPane.OutputString(codeElement.Parameters.Count + "\n");
    // Itemの列挙ができない模様
    /*
    for (var i = 0; i < codeElement.Parameters.Count; ++i) {
        // Itemが認識できない
        outputWindowPane.OutputString(codeElement.Parameters.Item(i) + "\n");
    }
    */
    outputWindowPane.OutputString(codeElement.DocComment + "\n");

}

時刻のインサート

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var date = new Date();

var day = date.getDate();
var month = date.getMonth() + 1;
var year = date.getYear();

var hours = date.getHours();
var minutes = date.getMinutes();

// Add a zero if single digit
if (day <= 9) day = "0" + day;
if (month <= 9) month = "0" + month;
if (minutes <= 9) minutes = "0" + minutes;
if (hours <= 9) hours = "0" + hours;

Macro.InsertText("//" + year + "/" + month + "/" + day + ", " + hours + ":" + minutes);

まとめ

VisualStudioのマクロは復活しましたが、デバッグ機能がない、すべての機能が使えるわけでもない、メンテナンスする気はないとうたっているので、使いどころは限られるようです。