LaTeXのエディタを作った(初心者)

※この記事はGUI初心者が初めてアプリを作ってみたという内容です。

# なぜ作ろうと思ったのか
この章は読み飛ばしOK。
ありがたいことにこの春に内定をいただき、あとは卒論を頑張るのみとなりました。
しかしここ二か月近く「学生時代に出来ることをやっておくべき」という言葉に甘え、FPGAを買ったり人工知能を教えるバイトを始めたりUnityやってみたり、就職先とは一切関係のないことばかりやって遊んでいました。
ですが私も来年から社会人。たまには適当にプログラム書くのをやめて、授業で習ったとおりの順番で何か作ってみることにしました。
せっかくなので普段使いできそうなアプリをつくることにしたい、という思いでLaTeXのエディタを作ることにしました。

# 仕様決定
まず「こんなものを作りたい」を固めます。
今までもやってはいましたが、具体的に固めたりはしていませんでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/7a9224ae-cdbe-02ae-2e96-18b409f8f3bd.png)

完成図は上の通り。
文字にすると以下の通りです。

* 左にソースコート、右に出力が表示される
* テンプレートが用意されている
* 保存や終了など、各種基本的な操作をショートカットキーから行える
* 未保存の状態で新規に立ち上げようとしたりアプリを終了しようとしたりすると確認ウィンドウが出る
* ファイル名と保存状態を上に表示する
* 保存すると自動でコンパイルされ、右の画面も更新される

# 設計
目的を達成するために、まずコンパイルの自動化が必要になります。
これをlatexc.pyというファイルで実現し、pyinstallerでexeファイル化したのちパスを通しコマンドとして使えるようにします。
また、GUIアプリですが、定数をまとめたconstants.pyと関数をまとめたfunctions.pyを作り、main.pyからそれらを呼び出して使う形にしようと思います。
大規模なアプリではないですが、十個以上の機能を備える予定なのでファイルを分けることで分かりやすくなるはずです。

# プログラミング
## latexc.py
texファイルのコンパイルの自動化。

```py
import os
import sys
import re
from pathlib import Path
import shutil

if len(sys.argv) < 2:
    raise ValueError("ファイル名を入力してください")
elif len(sys.argv) > 2:
    raise ValueError("ひとつのファイル名を入力してください")

# Windows用のファイルパス取得
file_path_w = Path(sys.argv[1])
# 拡張子なしのファイル名
file_name = file_path_w.name.replace(".tex", "")
# wsl用のファイルパス取得
file_path = str(file_path_w).replace("\\", "/")
file_path = re.sub(r"\D:", f"/mnt/{file_path[0].lower()}", file_path)

# コンパイルと中間ファイルの削除
# 完成したpdfファイルをソースファイルと同じ位置に移動
os.system(f"wsl uplatex {file_path}")
os.system(f"wsl dvipdf {file_name}")
os.system("del *.aux, *.dvi, *.log")
# 追記　修正しました(2022/5/28)。
# os.system(f"MOVE {file_name + '.pdf'}, {str(file_path_w).replace('.tex', '.pdf')}") 修正前
shutil.move(file_name + ".pdf", str(file_path_w).replace('.tex', '.pdf'))
```

これをpyinstallerでexeファイル化し、パソコンでパスを通しました。
コマンドプロンプトに「latexc test.tex」などと入力することでtest.texをコンパイルすることができます。

## constants.py
定数をまとめたファイル。
ショートカットキーを押したときに得られる文字列と、テンプレート用の文字列を定義しています。

```py
# ショートカットキーを押したときに得られる文字列
CTRL_S = "s:83"
CTRL_W = "w:87"
CTRL_N = "n:78"
CTRL_O = "o:79"
CTRL_S = "s:83"
CTRL_SHIFT_S = "S:83"
CTRL_Z = "z:90"
CTRL_Y = "y:89"
CTRL_X = "x:88"
CTRL_C = "c:67"
CTRL_V = "v:86"
BACKSPACE = "BackSpace:8"
DELETE = "Delete:46"
PAGE_UP = "Next:34"
PAGE_DOWN = "Prior:33"

# テンプレート用の文字列
REPORT = """\\documentclass[uplatex]{jsarticle}
\\usepackage{graphicx}
\\usepackage{here}
\\usepackage{amsmath}
\\begin{document}
\\begin{titlepage}
\\centering
\\vspace{30mm}
\\scalebox{2}{}\\\\
\\scalebox{1}{}\\\\
\\scalebox{1}{\\today}
\\end{titlepage}
\\newpage



\\end{document}"""

FIGURE = """\\begin{figure}[H]
\\centering
\\includegraphics[width=]{}
\\caption{}
\\label{}
\\end{figure}"""

TABLE = """\\begin{table}[H]
\\centering
\\begin{tabular}{}
\\end{tabular}
\\caption{}
\\label{
\\end{table}"""

ALIGN = """\\begin{align}

\\end{align}"""
```

## functions.py
各動作を関数としてまとめています。
ここで定義した関数をmain.pyから呼び出します。

:::note warn
こちらで使用しているfitzですが、「pip install fitz」とすると同じ名前の別のライブラリがインストールされます。
「pip install pymupdf」でインストールしてください。
:::

```py
from pathlib import Path
from copy import deepcopy
import os

import PySimpleGUI as sg
import tkinter as tk
import fitz

# 名前を付けて保存
save_as_custom_button = sg.Button(key="save_as",
                                  button_text="Save as...",
                                  target=(sg.ThisRow, -1),
                                  file_types=(("ALL Files", "*.*"),("LaTeX Files", "*.tex"),),
                                  initial_folder=None,
                                  default_extension="tex",
                                  disabled=False,
                                  tooltip=None,
                                  size=(None, None),
                                  s=(None, None),
                                  auto_size_button=None,
                                  button_color=None,
                                  change_submits=False,
                                  enable_events=True, # Trueに変更
                                  font=None,
                                  pad=(0, 0),
                                  k=None,
                                  metadata=None,
                                  button_type=sg.BUTTON_TYPE_SAVEAS_FILE,
                                  visible=False) # invisibleに変更

# ファイル選択
files_browse_button = sg.Button(key="file_browse",
                                button_type=sg.BUTTON_TYPE_BROWSE_FILES,
                                pad=(0, 0),
                                visible=False) # invisibleに変更

# 名前を付けて保存ポップアップ
save_as_popup_button = sg.Button(key="popup_saveas",
                                button_text="はい",
                                target=(sg.ThisRow, -1),
                                file_types=(
                                    ("ALL Files", "*.*"), ("LaTeX Files", "*.tex"),),
                                initial_folder=None,
                                default_extension="tex",
                                disabled=False,
                                tooltip=None,
                                size=(None, None),
                                s=(None, None),
                                auto_size_button=None,
                                button_color=None,
                                change_submits=False,
                                enable_events=True,  # Trueに変更
                                font=None,
                                pad=None,
                                k=None,
                                metadata=None,
                                button_type=sg.BUTTON_TYPE_SAVEAS_FILE,
                                bind_return_key=True)

# コンパイル
def pdf_compile(file_path: str) -> None:
    # latexcコマンドを使ってコンパイル
    os.system(f"latexc {file_path}")

# pdfファイル表示
def pdf_show(window: sg.Window, file_path: str) -> fitz.fitz.Document:
    pdf_file = None
    # texファイルを開いていた場合
    if file_path:
        pdf_file_path = file_path.replace(".tex", ".pdf")
        # コンパイル済みのpdfファイルが既に存在した場合
        if os.path.isfile(pdf_file_path):
            pdf_file = fitz.open(pdf_file_path)
            window["pdf"].Update(data=pdf_file[0].get_displaylist().get_pixmap().tobytes())
        
    return pdf_file

# pdfファイルの表示ページ変更
def pdf_page_update(window: sg.Window, pdf_file: fitz.fitz.Document, page: int) -> None:
    window["pdf"].Update(data=pdf_file[page].get_displaylist().get_pixmap().tobytes())

# 文字列をエディタにペースト
def paste(window: sg.Window, text: str) -> None:
    position = window["editor"].Widget.index("insert")
    window["editor"].Widget.insert(position, text)

# redo
def redo(event: str, widget: tk.Text) -> None:
    try:
        widget.edit_redo()
    except:
        pass

# 上書き保存
def save(file_path: str, text: str) -> None:
    with open(file_path, "w", encoding="UTF-8") as fp:
        fp.write(text)

# 名前を付けて保存
def save_as(window: sg.Window, text: str) -> str:
    try:
        save_as_custom_button.click()
        event, values = window.read(timeout=0)
        file_path = values["save_as"]
        window.TKroot.title("LaTeX editor - %s" % Path(file_path).name)
        save(file_path, text)
        return file_path
    except:
        return None

# 保存するか尋ねる
# 上書き保存または名前を付けて保存をしていないのに終了や新規を選択したときに使う
def ask_save(window: sg.Window, text: str,file_path: str) -> bool:
    is_cancel = True
    # ファイルを開いていなかった場合、新規に名前を付けて保存するか聞く
    if not file_path:
        q_text = "この内容を保存しますか？"
        save_button = deepcopy(save_as_popup_button)
    # そうでなかった場合、上書き保存するか聞く
    else:
        q_text = "上書き保存しますか？"
        save_button = sg.Button("はい")

    
    layout = [
        [
            sg.Text(q_text)
        ],
        [
            save_button,
            sg.Button("いいえ"),
            sg.Button("キャンセル")
        ]
    ]
    popup_window = sg.Window(
        "LaTeX editor",
        layout=layout
    )
    
    while True:
        event, values = popup_window.read()
        
        if event == "キャンセル":
            is_cancel = True
            break
        
        elif event == "いいえ":
            is_cancel = False
            break
        
        # 上書き保存の時のみ
        elif event == "はい":
            is_cancel = False
            save(file_path, text)
            break

        # 追記　追加しました(2022/5/28)
        elif event == sg.WIN_CLOSED:
            is_cancel = True
            break
    
    popup_window.close()
    return is_cancel
```

## main.py
アプリのメイン部分です。
functions.pyで定義した関数を条件分岐などで使い分けます。

```py
import os
import sys
import tkinter as tk
import re

import PySimpleGUI as sg
import pyperclip as pc

from functions import *
from constants import *

menu_def_jp = [["ファイル", ["&新規                         Ctrl+N",
                             "&開く                         Ctrl+O", 
                             "&上書き保存              Ctrl+S", 
                             "&名前を付けて保存    Ctrl+Shift+S", 
                             "&終了                         Ctrl+W"]],
               ["&編集", ["&元に戻す    Ctrl+Z",
                          "&再実行        Ctrl+Y",
                          "&切り取り    Ctrl+X",
                          "&コピー        Ctrl+C",
                          "&貼り付け    Ctrl+V"]],
               ["テンプレート", ["レポート",
#                                  "PowerPoint",
                                 "図",
                                 "表",
                                 "数式"]]
]               

layout = [
    [
        sg.MenuBar(menu_def_jp, key="menubar", pad=(0, 0))
    ],
    [
        sg.Multiline(key="editor", pad=(0, 0)),
        sg.Image(data=None, key="pdf", pad=(0, 0))
    ],
    [
        save_as_custom_button,
        files_browse_button
    ]
]

window = sg.Window(
    "latex editor",
    layout,
    margins=(0, 0),
    resizable=True,
    finalize=True,
    return_keyboard_events=True,
    enable_close_attempted_event=True
)
window.Maximize()
window["editor"].expand(expand_x=True, expand_y=True)
window["pdf"].expand(expand_x=True, expand_y=True)
window["editor"].Widget.configure(undo=True)
window["editor"].Widget.bind(
    "<Control-Key-Y>",
    lambda event, widget=window["editor"]: redo(event, widget)
)

file_path = None  # texファイルのパス
saved = True      # 上書き保存または名前を付けて保存されているか
values = ""       # 最初にold_valuesに入れる用
pdf_file = None   # 表示するpdfファイル
now_page = 0      # 今表示しているpdfファイルのページ数

while True:
    # GUI上部の文字列更新
    title = "LaTeX editor"
    # ファイルが開かれているとき
    if file_path:
        title +=" - " + Path(file_path).name
    # ファイルの開閉問わず未保存の時
    if not saved:
        title += "*"
    window.TKroot.title(title)
    
    old_values = values
    event, values = window.read()
    # 数字以外の一文字または数字またはBackSpaceまたはDeleteの入力を受け取った時
    if type(event) == str:
        if re.fullmatch(r"\D", event) or re.fullmatch(r"\d", event)\
           or event == BACKSPACE or event == DELETE:
            saved = False

    # ファイル
    if event in [CTRL_N, "新規                         Ctrl+N"]:
        # 未保存の場合は保存するかどうか聞く。
        # 操作のキャンセルを押された場合は何もしない。
        # 新規以外の他の項目も似たようなことをしている。
        is_cancel = False
        if not saved:
            is_cancel = ask_save(window, values["editor"], file_path)
        if is_cancel:
            continue
        
        file_path = ""
        saved = True
        window["editor"].Update("")
        
    elif event in [CTRL_O, "開く                         Ctrl+O"]:
        is_cancel = False
        if not saved:
            is_cancel = ask_save(window, values["editor"], file_path)
        if is_cancel:
            continue
        
        files_browse_button.click()
        event, values = window.read(timeout=0)
        file_path = values["file_browse"]
        
        if not "" == file_path:
            with open(file_path, "r", encoding="UTF-8") as fp:
                text = fp.read()
            window["editor"].update(text)
            pdf_file = pdf_show(window, file_path)
    
    elif event in [CTRL_S, "上書き保存              Ctrl+S"]:
        saved = True
        if file_path:
            save(file_path, values["editor"])
        else:
            file_path = save_as(window, values["editor"])
        pdf_compile(file_path)
        pdf_file = pdf_show(window, file_path)
    
    elif event in [CTRL_SHIFT_S, "名前を付けて保存    Ctrl+Shift+S"]:
        saved = True
        file_path = save_as(window, values["editor"])
        pdf_compile(file_path)
        pdf_file = pdf_show(window, file_path)
        
    if event in [CTRL_W, "終了                         Ctrl+W", sg.WIN_CLOSED, "-WINDOW CLOSE ATTEMPTED-"]:
        is_cancel = False
        if not saved:
            is_cancel = ask_save(window, old_values["editor"], file_path)
        if not is_cancel:
            break
    
    # 編集
    if event in [CTRL_Z, "元に戻す    Ctrl+Z"]:
        try:
            window["editor"].Widget.edit_undo()
        except:
            pass
    
    elif event in [CTRL_Y, "再実行        Ctrl+Y"]:
        try:
            window["editor"].Widget.edit_redo()
        except:
            pass
    
    elif event in ["切り取り    Ctrl+X"]:
        try:
            text = window["editor"].Widget.selection_get()
            pc.copy(text)
            window["editor"].Widget.delete(tk.SEL_FIRST, tk.SEL_LAST)
        except:
            pass
    
    elif event in ["コピー        Ctrl+C"]:
        try:
            text = window["editor"].Widget.selection_get()
            pc.copy(text)
        except:
            pass
    
    elif event in ["貼り付け    Ctrl+V"]:
        try:
            text = pc.paste()
            paste(window, text)
        except:
            pass
    
    # テンプレート
    if event == "レポート":
        saved = False
        paste(window, REPORT)
    
    elif event == "PowerPoint":
        pass
    
    elif event == "図":
        saved = False
        paste(window, FIGURE)
    
    elif event == "表":
        saved = False
        paste(window, TABLE)
    
    elif event == "数式":
        saved = False
        paste(window, ALIGN)
        
    # pdf
    if event == PAGE_DOWN:
        if pdf_file:
            now_page -= 1
            if now_page < 0:
                now_page = 0
            pdf_page_update(window, pdf_file, now_page)
    
    elif event == PAGE_UP:
        if pdf_file:
            now_page += 1
            if now_page >= len(pdf_file):
                now_page = len(pdf_file)-1
            pdf_page_update(window, pdf_file, now_page)

window.close()
```

# 完成
一通りの動作をテストし、普通にエディタのように使えることを確認しました。
コンパイルには若干時間がかかり、またコマンドプロンプトがいくつか起動して邪魔ですがなんとか行えました。

簡単に完成品の仕様をまとめます。

* ファイル
  * Ctrl+Nで新規作成、未保存の場合は保存するか聞く
  * Ctrl+Oで開く、未保存の場合は保存するか聞く
  * Ctrl+Sで上書き保存、ファイルを開いていない場合は名前を付けて保存
  * Ctrl+Shift+Sで名前を付けて保存
  * Ctrl+Wで終了
* 編集
  * Ctrl+Zで元に戻す
  * Ctrl+Yで再実行
  * Ctrl+Xで切り取り
  * Ctrl+Cでコピー
  * Ctrl+Vで貼り付け
* テンプレート
  * レポート(表紙など)
  * 図
  * 表
  * 数式
* ファイルを開いていない時GUI上部の文字は「LaTeX editor」
* ファイルを開いている時GUI上部の文字は「LaTeX editor - ファイル名」
* 未保存の時GUI上部の文字の末尾に「*」がつく
* pdf
  * PgUpでページアップ
  * PgDnでページダウン

テンプレートについてはショートカットキーがなく、クリックすることでカーソルがある位置にテンプレートが挿入されます。

# 参考文献
PySimpleGUIは意外と資料が少なく、以下二つを大いに参考にさせていただきました。

* [画面左のエディタ部分](https://novnote.com/pysimplegui-tips/617/)
* [画面右のpdf部分](https://qlitre-weblog.com/pysimplegui-pdf-reader/)