プログラムでおかえしできるかな

定年を過ぎて何かの役に立てないかなと始めた元SEのブログです

Excel viewerアプリの作り方(Tkinterでタブと表)【Python】

このエントリーをはてなブックマークに追加

f:id:juu7g:20211004094209p:plain:left:w120 PythonGUIアプリの勉強を兼ねてExcel viewer(エクセルビューア)を作成しました。
GUIにはTkinterを、エクセルの読み込みにはopenpyxlを使用しました。
Tkinterはタブ表示にNotebook、表表示にTreeviewを使用しました。
これらの使い方をサンプルコードで説明します。
また、サンプルはアプリとして実行できます。

目次

■アプリのサンプル画面

▼複数シートをタブ表示します
Excel viewer
▼タブごとに行の高さを変えます
Excel viewer
▼グラフは表示しません
Excel viewer

■できること

Excelファイルを読み込みその内容を画面に表示します。
複数シートをタブ表示します。
タブごとに行の高さを変えます。

おまけ的に CSV 出力もできます。

□画面表示の特徴

画面表示には次の特徴があります。

Tkinterの使用ウィジェット

Tkinter がサポートする GUI の部品をウィジェット (widget) と呼びます。

ここでは次のウィジェットを使用しています。
これらの使い方も説明します。

■必要なパッケージ

□必要な Python パッケージ

  • openpyxl

TkinterPythonの標準パッケージです。

Tkinterの使い方

Tkinter(ティーキンター)は Python が標準で提供している GUI パッケージです。
他のプログラムの開発ツールで提供されているようなルック&フィールな GUI ツールではありません。
従って、画面を構成するには、すべてコードを書く必要があります。

Tkinter を使用するには import が必要です。

   import tkinter as tk

慣例的に tk と別名を付けるようです。

□初歩的な使い方

ウィンドウを出すだけのサンプルです。
Python を起動して次の命令を入れるとウィンドウが出てきてアプリらしさをちょっとだけ体験できます。

import tkinter as tk     #tkと省略することが慣例のようです  
win = tk.Tk()               #ウィンドウの作成と表示  
win.title("Hello, World")    #タイトル設定  
win.geometry("400x300")      #ウィンドウサイズ設定  
lbl = tk.Label(win, text="ラベル")    #ラベルウィジェットの作成  
lbl.pack()                  #ラベルウィジェットの配置  
win.mainloop()              #イベント待ち  

□使用ウィジェット

ウィジェットは基本的にインスタンスを作成し、表示テキストや表示方法などを指定して使用します。

今回使用しているウィジェットです。

パッケージ ウィジェット 用途 見た目
tkinter Frame
フレーム
ウィジェットの受け皿
tkinter Label
ラベル
文字の表示
tkinter Entry
エントリー
テキストを入力するボックス
tkinter Button
ボタン
押すと処理が動くボタン
tkinter Checkbutton
チェックボックス
チェックでオン/オフを設定
tkinter Scrollbar
スクロールバー
画面をスクロール
tkinter.ttk Treeview
ツリービュー
ツリー表示やリスト表示
ここではリスト表示で使用
tkinter.ttk Notebook
ノートブック
タブ切り替え表示

ウィジェットに変数を関連付ける

ウィジェットの値と連動して変更される変数をウィジェット変数と呼びます。
今回は、ファイルパス用エントリー、日付変換チェック用チェックボックス、メッセージ表示用ラベルにウィジェット変数を関連付けます。

ウィジェット変数とウィジェットを関連付けるには、ウィジェットの属性 variable, textvariable にウィジェット変数を指定します。
ウィジェット変数のコンストラクタに value= 引数で初期値を与えられます。
ウィジェット変数の値の読み取りは get() メソッドで、値の書き込みは set() メソッドで行います。

他に、DoubleVar, BooleanVar があります。

【コード】
        self.var_excel_path = tk.StringVar(value="")
        self.ety_excel_path = tk.Entry(parent, textvariable=self.var_excel_path)
        
        self.var_dt = tk.IntVar(value=0)
        self.ckb_dt = tk.Checkbutton(parent, text="日付変換", variable=self.var_dt)

        self.msg = tk.StringVar(value="msg")
        self.lbl_msg = tk.Label(parent
                                , textvariable=self.msg
                                , justify=tk.LEFT
                                , font=("Fixedsys", 11)
                                , relief=tk.RIDGE
                                , anchor=tk.W)

□画面構成 - フレーム構造

プログラムを作るにあたって、どのような画面にするか決めなくてはなりません。画面構成を考えるということですね。
画面を構成するというのは、GUI 部品(ウィジェット)を画面に配置することです。
ウィジェットは設定したテキストに合わせてサイズが決まるので、そのまま使用しても画面構成上問題ないものと、広げないと見栄えが良くないものが出てきます。
ウィジェットを広げる場合、他のウィジェットとの位置関係で思ったように配置できないことがあります。
そのようなときに Frame ウィジェットを使用して区分けをして希望する位置に配置します。
引き出しの整理に使うトレイのようなイメージでしょうか。

今回の画面構成です。

  • u_frame(frame)
    • lbl_excel(Label)
    • ety_excel_path(Entry)
    • btn_exef_sel(Button)
    • btn_open(Button)
    • ckb_csvdt(Checkbutton)
    • btn_csv(Button)
    • lbl_msg(Label)
  • b_frame(frame)
    • note(Notebook)
      • frame1(Frame)
        • treeview1(Treeview)
        • h_scrlbar(Scrollbar)
        • v_scrlbar(Scrollbar)

※「u_frame」などはプログラムで使用した変数名です。
※図形の中の矢印はこの後説明するオプション fill の方向です。

入力をする部分と出力をする部分で大きくフレームを分けています。
結果を表示する部分は余った領域をすべて使用してできるだけ大きく表示させるためです。

更新:2021-10-20

ウィジェットの配置 - pack

ウィジェットインスタンスを作成した後に配置 (pack) をしないと表示されません。
配置には他に grid と place がありますが、基本的な pack を使用します。
pack とは、スーツケースに荷物をパッキングするようなイメージなんじゃないかなと勝手に思っています。

ウィジェットを pack すると side オプションで指定された方向から配置します。
その時に配置した方向と直行する方向には可能な限りの領域を割り当てます。
例えば、デフォルトの TOP で配置すると直行する横方向に可能なだけ領域を確保します。横一杯になるということですね。
ただし、これは領域の話で、実際のウィジェットの表示は他のオプションに依ります。

  • 【構文】ウィジェット.pack(オプション1 = 設定値, オプション2 = 設定値,・・・)

  • 【主なオプション】

    オプション 説明 設定値
    fill 割り当て領域内での引き伸ばし NONE:なし、X:水平方向、Y:垂直方向、BOTH:両方向
    expand 割り当て領域の詰め込み方向への拡大 True:する、False:しない
    side 次の方向から詰め込む TOP:上(デフォルト)、BOTTOM:下、LEFT:左、RIGHT:右


その他のオプション

オプション 説明 設定値
after 指定ウィジェットの後に配置 他のウィジェット
before 指定ウィジェットの前に配置 他のウィジェット
anchor 割り当て領域内の配置位置 NW,N,NE,W,CENTER,E,SW,S,SE(方角)
in 配置する親
ipadx,ipady 内部に空ける間隔 間隔(単位無しはピクセル)
padx,pady 外側に空ける間隔 間隔(単位無しはピクセル)

pack で「よく表示されない」という問題が発生しますが、私もそうでした、pack の順番を変えてあげることで解決する場合があるようです。

必ずではないようですが、配置の時に次のことを考慮しておくと良いみたいです。

  • expand オプションを True に指定して pack するウィジェットは、後から pack する。

ウィジェットの pack についてはこちらのサイトが詳しいです。

□動作をウィジェットに割り当てる

ウィジェットで何か操作した場合にそれに連動して動作するメソッドを割り当てることができます。
この動作(イベント)とメソッドを割り当てることをバインドと呼びます。
イベントには、キー入力可能なウィジェットでキー入力した場合やマウス操作した場合、ボタンをクリックした場合などがあります。

キーバインド

入力ボックスのエントリーウィジェットで Enter を押した時にエクセルファイルの読み込みが実行されるようにイベント登録をします。

  • 【構文】 ウィジェット.bind(シーケンス, コールバック関数, 追加フラグ)

  • 引数

    • シーケンス:イベント名(例:<Return>, <Button-1>)
    • コールバック関数:関数には event 引数が必要
      ウィジェットの command オプションと共通の場合は、関数の引数定義を event=None とする
    • 追加フラグ:ウィジェットに対してイベントを置き換えるか追加するか
      デフォルトは置き換え(引数を省略した場合)
    self.ety_excel_path.bind("<Return>", self.open_excel)   #Enterキーを押しても動作するように
    
    def open_excel(self, event=None):

◎ボタンウィジェット

ボタンウィジェットでは、ボタンを押した時に動作する処理を割り当てられます。
オプションの command で引数なしの関数を指定します。

        self.btn_open = tk.Button(parent, text="開く", command=self.open_excel)

※引数が必要な場合はラムダ式を使用します。(詳細は省略)

移動:2021-11-06

■画面の作成

先ほどの画面構成を踏まえて画面を作成します。

【処理】

  1. Frame の作成
    上側用:入力用テキストボックスや実行用ボタン用
    下側用:Notebookとその中のTreeview とスクロールバー用

  2. Frame の配置 (pack)

  3. 上フレームの中身の作成 ( create_input_frame メソッド)

    1. Excel:」文字用ラベル作成
    2. ファイルパス入力用ウィジェット変数の作成
    3. ファイルパス入力用エントリー作成
    4. ファイル選択ボタン作成
    5. 開くボタン作成
    6. チェックボックスウィジェット変数作成
    7. 日付変換用チェックボックス作成
    8. CSV 出力ボタン作成
    9. メッセージ用ウィジェット変数作成
    10. メッセージ用ラベル作成
    11. ウィジェットの配置 (pack)
    12. キーバインドの作成

※Notebook の中身の作成は「タブの実装」で解説します。

【コード】

▼フレームの作成

        self.u_frame = tk.Frame(bg="blue")      # 背景色を付けて配置を見る
        self.b_frame = tk.Frame(bg="green")     # 背景色を付けて配置を見る
        self.note = ttk.Notebook(self.b_frame)
        self.u_frame.pack(fill=tk.X)
        self.b_frame.pack(fill=tk.BOTH, expand=True)
        self.note.pack(fill=tk.BOTH, expand=True)
        self.create_input_frame(self.u_frame)

▼上フレームの中身の作成

    def create_input_frame(self, parent):
        """
        入力項目の画面の作成
        上段:入力ファイルパス、ファイル選択ボタン、開くボタン、日付変換チェックボックス、CSV出力ボタン
        下段:メッセージ
        """
        self.lbl_excel = tk.Label(parent, text="Excel:")
        self.var_excel_path = tk.StringVar(value="")
        self.ety_excel_path = tk.Entry(parent, textvariable=self.var_excel_path)
        self.btn_f_sel = tk.Button(parent, text="ファイル選択", command=self.select_file)
        self.btn_open = tk.Button(parent, text="開く", command=self.open_excel)
        self.var_dt = tk.IntVar(value=0)
        self.ckb_dt = tk.Checkbutton(parent, text="日付変換", variable=self.var_dt)
        self.btn_csv = tk.Button(parent, text="CSV出力", command=self.write_csv, state="disable")
        self.msg = tk.StringVar(value="msg")
        self.lbl_msg = tk.Label(parent
                                , textvariable=self.msg
                                , justify=tk.LEFT
                                , font=("Fixedsys", 11)
                                , relief=tk.RIDGE
                                , anchor=tk.W)
        self.lbl_msg.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)    # 先にpackしないと下に配置されない
        self.lbl_excel.pack(side=tk.LEFT, fill=tk.BOTH)
        self.ety_excel_path.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.btn_csv.pack(side=tk.RIGHT, fill=tk.Y)
        self.ckb_dt.pack(side=tk.RIGHT, fill=tk.Y)
        self.btn_open.pack(side=tk.RIGHT)
        self.btn_f_sel.pack(side=tk.RIGHT)
        self.ety_excel_path.bind("<Return>", self.open_excel)   #Enterキーを押しても動作するように

■タブの実装(Notebookウィジェットの使い方)

Notebook ウィジェットはタブ切り替えを提供するウィジェットです。
Notebook ウィジェットtkinter.ttk パッケージに入っているので使用には import が必要です。

   import tkinter.ttk as ttk

慣例的に ttk と別名を付けるようです。

この章では次の内容について説明します。

この章での説明

□タブの削除 - forget

  • 【構文】 notebook.forget(タブ指定)

既に存在するタブがある場合、forget メソッドで削除できます。 引数にタブを指定します。
タブ指定の一つに CURRENT があり、現在選択中のタブを指します。
次のコードは、タブのクリアを行います。

【コード】

        # notebookの既存のタブを削除
        while self.note.tabs():
            self.note.forget("current")

□タブの追加 - add

タブの追加には、add メソッドを使用します。
引数に追加するウィジェットを指定します。通常、Frame にすることが多いようです。
オプションとしてタブ名を指定する text= がよく使われます。

【コード】

        parent.add(frame1, text=tab_name)

□Notebookのタブの画面作成

画面構成にあるようにタブの中身を作成します。
Frame を用意して Treeview とスクロールバーを作成します。
スクロールバーは縦横を作成します。

【処理】

【コード】

▼タブ用フレームの中身の作成

    def create_tree_frame(self, parent:ttk.Notebook, tab_name="") -> ttk.Treeview:
        """
        Treeviewとスクロールバーを持つframeを作成し、Notebookにaddする。
        frameは、Treeviewとスクロールバーをセットする
        Treeviewは、listview形式、行は縞模様
        Args:
            ttk.Notebook:   ttk.Notebook
            string:         tab_name
        Returns:
            Treeview:       ツリービュー
        """
        # tagを有効にするためstyleを更新 tkinter8.6?以降必要みたい
        # 表の文字色、背景色の設定に必要
        self.style = ttk.Style()
        self.style.map('Treeview', foreground=self.fixed_map('foreground')
                                 , background=self.fixed_map('background'))
        # タブごとのスタイルの設定
        self.style.configure(tab_name + ".Treeview")
        # frameの作成。frameにTreeviewとScrollbarを配置する
        frame1 = tk.Frame(parent, bg="cyan")
        # Treeviewの作成
        treeview1 = ttk.Treeview(frame1, style=tab_name + ".Treeview")
        treeview1["show"] = "headings"      # 表のデータ列だけを表示する指定
        treeview1.tag_configure("odd", background="ivory2")     # 奇数行の背景色を指定するtagを作成
        # 水平スクロールバーの作成
        h_scrollbar = tk.Scrollbar(frame1, orient=tk.HORIZONTAL, command=treeview1.xview)
        treeview1.configure(xscrollcommand=h_scrollbar.set)
        # 垂直スクロールバーの作成
        v_scrollbar = tk.Scrollbar(frame1, orient=tk.VERTICAL, command=treeview1.yview)
        treeview1.configure(yscrollcommand=v_scrollbar.set)
        # pack expandがある方を後にpackしないと他が見えなくなる
        h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)          # 先にパックしないと表示されない
        v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)           # 先にパックしないと表示されない
        treeview1.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        parent.add(frame1, text=tab_name)
        return treeview1

□タブごとのスタイル

エクセルのシートごとに行の高さを一番高い行に合わせるために、タブ(実際には Treeview )ごとにスタイルを作成します。

タブの中には Treeview ウィジェットを作成します。
Treeview ウィジェットは行の高さをスタイルで指定 ( rowheight= オプション)します。
ウィジェットのスタイルは同じクラスのウィジェットで共通です。
エクセルのシートごとに行の高さが異なる場合、Notebook のタブに作成する Treeview ごとにスタイルを変える必要があります。

すべてのウィジェットにはデフォルトのスタイルがあります。
デフォルトのスタイル名はウィジェット名の前に「T」が付きます。
ただし、Treeview の場合は例外で Treeview のままです。

デフォルトのスタイルを元に派生した別のスタイルを指定することができます。
その場合、スタイル名を「新しい名前.古い名前」とします。

あるスタイルに基づく新しいスタイルの作成方法は、

  • Styleのインスタンスを作成
  • 「新しい名前.古い名前」形式の名前を使用してconfigure()メソッドを呼び出す

これを利用してタブ(実際には Treeview )ごとに異なる Treeview のスタイルを作成します。
スタイルの新しい名前は、タブ名にします。
つまり、「タブ名.Treeview」で新しいスタイルを定義します。

【コード】

        self.style = ttk.Style()
        self.style.map('Treeview', foreground=self.fixed_map('foreground')
                                 , background=self.fixed_map('background'))
        # Treeviewの作成
        treeview1 = ttk.Treeview(frame1, style=tab_name + ".Treeview")


        # Treeviewの行の高さを変更 # タブごとのスタイルの設定
        self.style.configure(sheet_name1 + ".Treeview", rowheight = 18 * max_row_lines)
更新:2021-12-10

■表の実装(Treeviwウィジェットの使い方)

Treeview ウィジェットはツリー形式や表形式でデータを表示するウィジェットです。
Treeview ウィジェットtkinter.ttk パッケージに入っているので使用には import が必要です。

   import tkinter.ttk as ttk

慣例的に ttk と別名を付けるようです。

表形式で表示する時の基本的な使い方です。

  1. インスタンス作成
  2. 配置 (pack)
  3. 表示形式の指定 (treeview["show"])
  4. 列の定義 (treeview.column)
  5. 見出しの設定 (treeview.heading)
  6. データ挿入 (treeview.insert)

この章では次の内容について説明します。

この章での説明

□表(Listview)形式の指定

Treeview はツリー形式で表示する部分と、表形式で表示する部分を持っています。
両方、どちらか片方だけの表示を選択できます。
デフォルトは両方を表示するので表形式だけを指定します。
指定は、インスタンスの show オプションか、Treeview のコンストラクタの show= 引数で行います。

        self.tree["show"] = "headings"      # listview形式の指定
        # or
        ttk.Treeview(parent, show="headings")
オプション名 説明 設定値
show 表示対象を指定 ・"tree":ツリー表示部分の表示
・"headings":表形式部分の表示
・["tree","headings"]:すべて(デフォルト)

□1行おきに背景色を設定する

行修飾を行うにはタグ (tag) を使用します。
タグはタグ名で区別して属性の異なるタグを複数設定できます。
行に対しても複数のタグを設定できます。

タグで使用できる主な属性 (オプション)

オプション名 説明
background 背景色
foreground 前景色
font フォント

◎タグの使い方

タグ設定とデータ追加はどちらが先でも動作します。

  • treeviewにタグ (tag) を設定
    treeview.tab_configure(タグ名, オプション)
  • タグを指定してデータ追加
    データ追加時:treeview.insert(…, tags=タグ名)

◎背景色指定時のバグ

リンクのサイトにあるように Treeview の色付けにはバグがあるようです。
fixed_map メソッドを再定義して style の map メソッドを実行する必要があるようです。

【コード】

▼Treeview の色設定のバグフィクス

    def fixed_map(self, option):
        # Fix for setting text colour for Tkinter 8.6.9
        # From: https://core.tcl.tk/tk/info/509cafafae
        #
        # Returns the style map for 'option' with any styles starting with
        # ('!disabled', '!selected', ...) filtered out.

        # style.map() returns an empty list for missing options, so this
        # should be future-safe.
        return [elm for elm in self.style.map('Treeview', query_opt=option) if
            elm[:2] != ('!disabled', '!selected')]
        # tagを有効にするためstyleを更新 tkinter8.6?以降必要みたい
        # 表の文字色、背景色の設定に必要
        self.style = ttk.Style()
        self.style.map('Treeview', foreground=self.fixed_map('foreground')
                                 , background=self.fixed_map('background'))

▼タグの設定

        # Treeviewの作成
        treeview1 = ttk.Treeview(frame1, style=tab_name + ".Treeview")
        treeview1["show"] = "headings"      # 表のデータ列だけを表示する指定
        treeview1.tag_configure("odd", background="ivory2")     # 奇数行の背景色を指定するtagを作成

▼タグを指定してデータ追加

        # treeviewに要素追加。背景はtagを切り替えて設定
        tree.delete(*tree.get_children())   # Treeviewをクリア
        for i, row in enumerate(rows):
            tags1 = []              # tag設定値の初期化
            if i & 1:               # 奇数か? i % 2 == 1:
                tags1.append("odd") # 奇数番目(treeviewは0始まりなので偶数行)だけ背景色を変える(oddタグを設定)
            tree.insert("", tk.END, values=row, tags=tags1)     # Treeviewに1行分のデータを設定

□列の定義

Treeview で列を操作するには、列の定義が必要です。
これは見出し名とは別で列操作のための識別子(カラム識別子)です。
指定は、インスタンスの columns オプションか、Treeview のコンストラクタの colums= 引数で行います。

   treeview = Treeview(parent)
    treeview["columns"] = ["列1", "列2", "列3"]
    # or
    ttk.Treevew(parent, columns=["列1", "列2", "列3"])

実際のコードでは、エクセルに列定義があるかどうかわからないので1で始まる整数を設定します。

【コード】

        # 列定義の作成(現状は1開始の整数)
        columns_ = [i for i in range(1, ws.max_column + 1)]    # 列定義を列数分行う。1スタート

        self.tree["columns"] = columns                  # treeviewの列定義を設定

□見出しの設定

見出しの設定は、heading メソッドで行います。

  • 【構文】 treeview.heading("列1", text="列名")

列定義で指定した名前で列を指定して1列ずつ設定します。

合せて列の幅も見出しの文字の長さで設定します。
Font クラスの measure メソッドで文字列からピクセル値を取得し、
column メソッドで width= 引数を指定して設定します。

【コード】

        font1 = tkFont.Font()
        for col_name in columns:
            self.tree.heading(col_name, text=col_name)  # 見出しの設定
            width1 = font1.measure(col_name) + 10       # 見出しの文字幅をピクセルで取得
            self.tree.column(col_name, width=width1)    # 見出し幅の設定

□データの挿入

データの挿入は、insert メソッドで行います。

  • 【構文】treeview.insert(parent, index, iid=None, オプション)
  • よく使う使い方
    【構文】treeview.insert("", tk.END, value=データ列の値 (リスト))
  • オプション
    • parent:"" で最上位
    • index:tk.END で最後尾に追加
    • iid=None(省略している)で iid の作成をお任せ
      "I001" から順に割り当てる
    • image=で画像(PhotoImage)オブジェクトを指定
    • text=でツリーカラムのテキストを指定
    • tgs=でタグを指定
    • open=で子アイテムの展開を指定

【コード】

    tree.insert("", tk.END, values=row, tags=tags1)     # Treeviewに1行分のデータを設定
追加:2021-12-10

□列の幅の自動調整

Treeview の列の幅は、列名で設定します。

  • 【構文】 treeview.column(列名, width=幅)

列の幅は設定する文字の長さを計測します。

   import tkinter.font as tkFont
    tkFont.Font().measure(文字列)  

【処理】

  1. 見出しの文字幅で設定

    1. 見出しの個数分、以下を繰り返す
      1. 見出しの文字列の幅を計算
      2. 列幅として設定
  2. 結果セットの文字幅で再設定

    1. 列の個数分、以下を繰り返す
      1. 同じ列のあるデータを文字に変換しその長さが最も長いデータを求める
        max([x[i] for x in rows], key=lambda x:len(str(x)))
        同じ列のデータをリストにして max 関数で長さを調べる
      2. 文字列データの場合、改行を含む場合に改行で区切った中で長さが最も長いデータを求める
        max(max_str.split("\n"), key=len)
      3. 求めたデータの文字幅を計算
        font1.measure(max_str)
      4. 現在の列幅の設定値を取得
        tree.column(tree['columns'][i], width=None)
        width=None で呼び出す
      5. 求めた文字幅が現在の列幅より大きい時は列幅を再設定
        tree.column(tree['columns'][i], width=width1)

【コード】

▼見出しに対して列の幅を設定

        font1 = tkFont.Font()
        for col_name in columns:
            self.tree.heading(col_name, text=col_name)  # 見出しの設定
            width1 = font1.measure(col_name) + 10       # 見出しの文字幅をピクセルで取得
            self.tree.column(col_name, width=width1)    # 見出し幅の設定

▼データに対して列の幅を設定

        font1 = tkFont.Font()
        # 要素の長さにより列幅を修正
        for i, _ in enumerate(rows[0]):     # 列数分回す(1行目の要素数分)
            # 同じ列のデータをリストにし列の値の長さを求め、最大となる列のデータを求める。
            # 値は数字もあるので文字に変換し長さを求める。また、Noneは'None'となるので'    'とする。
            max_str = max([x[i] for x in rows], key=lambda x:len(str(x))) or "    "
            # 求めたものが文字列だったら、改行された状態での最大となるデータを求める。
            # 厳密にはこの状態で最大となるデータを探さなければならないが割愛
            if type(max_str) is str:
                max_str = max(max_str.split("\n"), key=len)
            width1 = font1.measure(max_str) + 10   # 文字幅をピクセルで取得
            header1 = tree.column(tree['columns'][i], width=None) # 現在の幅を取得
            # 設定済みの列幅より列データの幅の方が大きいなら列幅を再設定
            if width1 > header1:
                tree.column(tree['columns'][i], width=width1)    # 見出し幅の再設定

□行の高さの自動調整

本来は、行ごとに高さの調整がしたかったのですが、そのやり方が分かりませんでした。
自分のなかでは、できないと判断しました。

仕方なく、シートに対しては同じ行の高さで、ただし、データが見えるように(行の高さを後から変更できないので)一番高い行に合わせて設定します。
シートによって一番高い行の高さは異なるので、シートごとに行の高さを変えます。
※シートごとに設定を変える方法は「タブごとのスタイル」を参照

考え方は簡単で、セルのデータが複数行の場合の行数を求めて、それを元に高さを設定します。

【処理】

  1. 列のデータを取得
    [s for s in itertools.chain.from_iterable( self.dict_tables.get( sheet_name1)[0]) if type(s) is str]
    辞書から値(rows, columnsのタプル)を取得、添え字0がrows
    rows は openpyxl で取得した rows をリストに変換したもの
    2次元のリストなので chain.from_iterable で平坦化してNoneを除いて取得

  2. 対象がなければ処理を抜ける

  3. 抽出したリストの要素の中で改行の数の最も多い要素を取得
    max(cells, key=lambda x:x.count("\n"))
    max 関数でリストの中から改行文字「\n」の数の最大値を取得
    max 関数で引数を伴うメソッドで値を判断する場合 lambda 式を使用

  4. Treeview クラスのスタイルを設定
    Treeview ごとのスタイルとして設定するため「シート名.Treeview」で設定
    Treeview の行の高さは rowheight に設定
    設定値は px 単位なので1行の高さを18pxとして計算

【コード】

            # 一番行数の多い行に合わせて高さを設定する
            # 2次元のデータを平坦化しstr型だけを抽出する
            cells = [s for s in itertools.chain.from_iterable(self.dict_tables.get(sheet_name1)[0]) if type(s) is str]
            if not cells:
                continue    # 対象がない場合は抜ける
            # 抽出したリストの要素の中で改行の数の最も多い要素を取得
            longest_cell = max(cells, key=lambda x:x.count("\n"))
            max_row_lines = longest_cell.count("\n") + 1             # 改行の数を数える
            self.style.configure(sheet_name1 + ".Treeview", rowheight = 18 * max_row_lines)

■スクロールバーの設置

スクロールバーはスクロールバーウィジェットを作成してスクロールさせたいウィジェットと関連付けします。

□コンストラク

  • 【構文】 tk.Scrollbar(親ウィジェット, オプション)
  • 【構文】 tk.Scrollbar(parent, orient=方向, command=対象ウィジェット.?view) (?:x or y)
    よく使うオプションを使った時の構文

  • 【主なオプション】

    オプション 説明 設定値
    orient スクロールの方向 HORIZONTAL:水平、VERTICAL:垂直
    command スクロール対象ウィジェットの移動メソッド メソッド名
    width スクロールバーの幅(水平の場合は高さ、垂直の場合は幅) デフォルトは16

□対象ウィジェットの割り付け

【処理】

  1. インスタンス作成
    tk.Scrollbar(frame1, orient=tk.HORIZONTAL, command=treeview1.xview)

    • 第一引数で親ウィジェットを指定
    • orient で垂直 (VERTICAL) 、水平 (HORIZONTAL) を指定
    • command でウィジェットの方向を指定 ※垂直方向の場合は x が y
  2. スクロールバーを対象ウィジェットに関連付け
    treeview1.configure(xscrollcommand=h_scrollbar.set)

    • xscrollcommand は水平スクロール発生時の呼び出しメソッドを指定
      ここにスクロールバーの set メソッドを指定
      ※垂直方向の場合は x が y 、h が v

【コード】

        # 水平スクロールバーの作成
        h_scrollbar = tk.Scrollbar(frame1, orient=tk.HORIZONTAL, command=treeview1.xview)
        treeview1.configure(xscrollcommand=h_scrollbar.set)
        # 垂直スクロールバーの作成
        v_scrollbar = tk.Scrollbar(frame1, orient=tk.VERTICAL, command=treeview1.yview)
        treeview1.configure(yscrollcommand=v_scrollbar.set)
        # pack expandがある方を後にpackしないと他が見えなくなる
        h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)          # 先にパックしないと表示されない
        v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)           # 先にパックしないと表示されない

■ファイル選択ダイアログ

Tkinter にはファイル選択用のダイアログが用意されています。
tkinter.filedialog パッケージに入っているので使用には import が必要です。

   from tkinter import filedialog

ダイアログを開いて選択されたファイル名を取得するには askopenfilename メソッドを使用します。

  • 【構文】filedialog.askopenfilename(オプション)

  • 【オプション】

    オプション 説明 設定値
    parent 指定したウィンドウの上にダイアログを表示 ウィジェット
    title ダイアログのタイトルを指定 文字列
    initialdir ダイアログを表示した時に開くディレクトリを指定
    initialfile ダイアログを表示した時に選択されているファイル名を指定
    filetypes 選択可能なファイルの種類を指定 (ラベル, パターン)のタプルのリスト(ワイルドカード可)
    multiple 複数ファイル選択の許可を指定 True:許可、False:許可しない

□filetypes

ファイルの種類の選択は、ワイルドカードが指定できますが、一般的なワイルドカードとは意味が異なるようです。
特別な指定として "*" があり、すべてのファイルが対象です。

  • 【構文】[("表示名1", ".拡張子1 .拡張子2..."), ("表示名2", ".拡張子3 .拡張子4...")...]

    例:filetypes=[("xls", ".xls .xlsx"), ("csv", ".csv")]

【処理】

  1. オプションで開くファイルの拡張子を指定してファイルダイアログを開く
  2. 取得したファイル名をエントリーウィジェットに対応付けている変数に設定

【コード】

    def select_file(self, event=None):
        """
        ファイル選択ダイアログを表示。選択したファイルパスを保存
        """
        file_path = filedialog.askopenfilename(filetypes=[("XLSX/XLSM", ".xlsx .xlsm"), ("All", "*")])
        self.var_excel_path.set(file_path)

■エクセルファイルの操作(openpyxlの使い方)

エクセルファイルを扱うには、openpyxl が便利です。
システムにエクセルがインストールされている必要がないところがまた使いやすいです。
使用には import が必要です。

   import openpyxl as oxl
    import openpyxl.chartsheet as oxlch
    import openpyxl.utils.datetime as oxldt

長いので別名を付けています。

既存のエクセルファイルを読み込んで使用する場合の基本は次の通りです。

  1. エクセルファイルを読み込む(ワークブックオブジェクトができる)
  2. ワークブックオブジェクトからワークシートオブジェクトを参照する
  3. ワークシートオブジェクトからセルオブジェクトを参照する

この章では次の内容について説明します。

この章での説明

□ワークブックの読み込み

  • 【構文】load_workbook(filename, read_only=False, keep_vba=False, data_only=False, keep_links=True)

    戻り値は、workbook クラスのインスタンスです。

  • 【オプション】

    名前 説明 設定値
    filename 開くパス or ファイルのようなオブジェクト 文字列 or バイナリーモードで開いたファイルライクなオブジェクト
    read_only 読み取り専用かどうか True:読み取り用、False:編集可
    keep_vba vbaコンテンツを保存するかどうか True:保存する(編集不可)、False:保存しない
    data_only 数式を含むセルの扱い True:Excelが最後にシートを読み取ったときに保存された値、False:数式
    keep_links 外部ワークブックへのリンクを保持するかどうか True:を保持する、False:保持しない

    エクセルファイルのデータ表示のために読み込むのであれば、read_only=True と data_only=True を指定します。

【処理】

  1. エクセルファイルをロード
    オプションで読み取り専用(read_only=True)と式の値(data_only=True)を取得するように設定
    oxl.load_workbook(filename=file_name, read_only=True, data_only=True)

□チャートシートの対応

ワークブックにチャートシートが含まれている場合、列定義を作成しようとしてシートの max_column を参照しようとするとエラーになります。
これは、openpyxl がチャートシートとデータシートでクラスを分けているためです。
従って、読み込んだシートがチャートシートかどうかで処理を分ける必要があります。

本来であれば、チャートの表示をしたいのですが、ここでは、処理をスキップします。

【処理】

  1. シートがチャートシートかどうか判断
  2. チャートシートなら「chartsheet」というセルデータ相当のデータを返す

    【コード】

    # Chartsheetの場合、コメントだけにする
    if isinstance(ws, oxlch.Chartsheet):
        tables[ws_name] = ([("Chartsheet",)], [1])
        continue

□シートの中身が読めない場合の対応

シートの中身が普通のデータであるにもかかわらず、読めない場合があります。
これは、エクセルファイルを読み取り専用モードで読み取る場合に起きることがあるようです。
エクセルファイルを作成したライブラリやアプリに問題があり、ファイルで提供されているメタデータが誤っている場合に起こりえます。

これを回避するためにシートの大きさを確認し、必要なら再計算します。

【処理】

  1. ワークシートの大きさを確認
    ws.calculate_dimension(True)
    A1:A1が返る場合、誤っていると判断します
  2. ワークシートの大きさリセットする
    ws.reset_dimensions()
  3. 再計算するために、再度ワークシートの大きさを確認
    ws.calculate_dimension(True)
    引数を True にしないと再計算しません
    ws.max_rowとws.max_columnが更新される

【コード】

    # 寸法が正しくない場合、再計算する
    if ws.calculate_dimension(True) == "A1:A1":
        # 空のシートはcalculate_dimensionでエラーになるので除く
        if ws["A1"].value is not None:
            ws.reset_dimensions()
            ws.calculate_dimension(True)

□日時の変換 - 書式設定の反映

エクセルファイルで表示形式が「ユーザー定義」のセルは、openpyxl では「標準(General)形式」として読み込まれます。
この時、データが日付の場合、シリアル値で返ります。 そのままでは大きな整数にしか見えないので、from_excel()関数でシリアル値をdatetime.datetime型に変換する必要があります。

ただし、これは元々表示形式が「標準(General)形式」で数字であるデータと区別がつきません。
したがって、変換するかどうかは指定するようにします。

また、変換にあたって、リードオンリー(この方が処理時間も短いし、元のファイルを壊さない)で読み込んでいるためか、セルの値がタプルで返ります。
そのままでは更新できないので、リストに変換します。

あわせて日時データでエクセル標準の書式を使用している場合にはその書式を適用します。
書式は、エクセルと Python で異なるので、変換して適用します。
※すべてのケースには対応できていません。

  • 日時データがシリアル値に変換されたかどうかの判断

    • 【条件】data_type が n で number_format が General
  • 日時データに変換可能な書式があるかどうかの判断

    • 【条件】data_type が d で number_format が General 以外
  • セルクラスのプロパティの一部

    名前 説明
    value
    data_type データタイプ(n:数値, s:文字, d:日付)
    number_format 書式(エクセルがユーザ指定書式だった場合はGeneralに置き換わる)
  • 日時などの書式

    種類 エクセル Python
    yy,yyyy:西暦、e,ee:和暦、g,gg,ggg:元号 %y,%Y:西暦(2,4)
    m,mm:数字、mmm,mmmm,mmmmm:英語(3,full,1) %m:数字(2)、%b,%B:英語(3,full)
    d,dd %d
    曜日 aaa,aaaa:漢字、ddd,dddd:英語 %a,%A
    時間 h,hh:時、m,mm:分、s,ss:秒、AM/PM,am/pm,A/P,a/p:AM/PM %H,%I:時(24,12)、%M:分、%S:秒、%p:AM/PM
    @ @"文字列"として、「セルの値文字列」と文字列を付加する
    ; 書式の区切り。複数の書式を設定できる
    _ 半角空白

【処理】

  1. 表示形式が「ユーザー定義」のセルか判断
  2. そうであればシリアル値を Datetime 値に変換して返る
  3. 表示形式が指定されている日付データか判断
  4. そうであれば、エクセル用記述の表示形式を Python 用記述に変換
    1. ; を区切りとして複数書式を指定できるので ; 以降をクリア
    2. " を削除
    3. 年:yyyy%Y へ、yy%y へ変換
    4. 月:連続する m%m へ変換
      ※月。分との区別は未対応
    5. 日:連続する d%d へ変換
  5. 表示形式を適用した文字列に置き換えて返る

【コード】

        # セル値の取得  DateTimeのシリアル値の変換も
        rows1 = list(list(x) for x in ws.values)    # 全ての値を取得 変更できるようにリストに変換
        for r, row in enumerate(ws.rows):
            for c, cell1 in enumerate(row):
                conv_value = self.conv_cell_excel2python(cell1, conv_dt)
                # 変換されていたら置き換える
                if conv_value:
                    rows1[r][c] = conv_value


    def conv_cell_excel2python(self, cell, conv_dt:bool) -> str:
        """
        エクセルのデータで変換が必要なものを変換する
        - シリアル値が返っている日付データ(ただし、自動判別できないので指定してもらう)
        - 書式が設定されている日付
        Args:
            cell:   openpyxlのcell
            bool:   日付のシリアル値を変換するかどうか
        Returns:
            str:    セルの表示用の内容
        """
        # 日付のシリアル値を変換(エクセルでユーザー定義の書式が設定された場合)
        if conv_dt and cell.number_format == "General" and cell.data_type == "n":
            return oxldt.from_excel(cell.value)
        # 書式のある日付を変換
        if cell.data_type == "d" and cell.number_format != "General" and cell.number_format != None:
            _nf = self.conv_format_excel2python(cell.number_format)
            return cell.value.strftime(_nf)

    def conv_format_excel2python(self, nf:str) -> str:
        """
        エクセル用の書式をPythonの書式に変換
        現在、日付のみ対応
        Args:
            str:    エクセルでの書式
        Returns:
            str:    Pythonでの書式
        """
        nf = re.sub(";.+", "", nf)     # ;以降を削除。エクセルは書式を「;」で区切って複数指定できるので2番目以降は無視
        nf = nf.replace('"', "")       # '"'を削除
        nf = nf.replace("yyyy", "%Y").replace("yy", "%y")   # 年
        nf = re.sub("[m]+", "%m", nf)  # 月。分との区別は未対応
        nf = re.sub("[d]+", "%d", nf)  # 日
        return nf

■全体のソース

全体のソースはこちらから取得できます。
取得先:GitHub juu7g/Python-Excel-Viewer

■バイナリ

pyinstaller でバイナリ( exe ) ファイルを作成しました。

  • 作成コマンド: pyinstaller -F --noconsole ファイル名

□バイナリ取得先

バイナリも公開します。
こちらから取得してください。Githubからダウンロード

更新:2021-10-08

□使い方

  • インストール
    ダウンロードしたzipファイルを任意のフォルダで解凍します

  • 実行
    Excel_viewer.exeを実行します

  • 操作

    1. ファイル選択ボタンをクリックしファイルを選択
    2. 日付変換する場合はチェックを付ける
    3. 開くボタンをクリック
      内容が表示されます
    4. CSV出力する場合はCSV出力ボタンをクリック
  • アンインストール
    解凍したファイルをすべて削除します

追加:2021-10-08

□使用上の注意

制限事項があります。

  • 制限事項
    • グラフや図は表示しません
    • 日付の変換は全データを対象にします
      正しく数字が出ているデータも条件に合えば変換されます
    • 日付の書式の変換はすべてのパターンを網羅していません
    • 色付けなどの書式設定には対応していません

■さいごに

行ごとに高さを変えるのはできないと判断しましたが、それっぽい画像を見たことがあります。
でも、それは Treeview ウィジェットの Tree 表示でした。
ひょっとしたら、Tree 表示を併用したら、行ごとに高さを変えられるのかもしれません。
機会を見つけて試してみたいと思います。

偶然にもサンプルにしたエクセルのファイルでいろいろと表示できないパターンに遭遇しました。
それらを対応することで色々と勉強になりました。
他にも表示できないエクセルファイルが存在するかもしれません。
もし、お気づきのファイルがあれば、ご連絡いただければ、トライさせていただきたいと思います。(結果は分かりませんが・・・)

グラフや図についても今回はスルーしています。
機会を見つけて試してみたいと思います。

あわせて読みたい - openpyxl関連記事 エクセルの表からMarkdownの表に変換【Python】
あわせて読みたい - Tkinter関連記事

更新:2021-11-06

□ご注意

本記事は次のバージョンの下で動作した内容を元に記述しています。

■更新情報

  • 2021-10-08
    • CSV出力が動作していなかった問題を修正
追加:2021-10-08

■参考

投稿: 、更新: