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

※この記事は、[LaTeXのエディタを作った(初心者)](https://qiita.com/tt_and_tk/items/48c10390c36d16c54c3e)の続きです

# なぜ続きを作ろうと思ったのか
前回の記事で、とりあえずtexファイルの編集とコンパイルや閲覧が可能になりました。
しかし不満は残ります。
まず、pdfが見づらいということ。これは由々しき問題です。
私はノートパソコンを主に使っているので、普段pdfファイルを触る時はEdgeで開いてマウスパッドから拡大や縮小・ページ移動などを楽々行っています。
LaTeXエディタでもEdgeみたいなことができたらいいな、と思ったところでひらめきました。Edgeでpdfファイルを開けばいいじゃないかと。
次の問題は、LaTeXのよく使うコマンドをGUIで書き込めるようにしたい、というもの。私は普段コマンドを直接書き込んでtexファイルを編集しています。しかし時間が空けばコマンドを忘れますし、たまにしか使わないコマンドは使うたびに調べるという非効率なことを行っていました。
まとめると、

* Edgeでpdfファイルを開く
* コマンドを打ち込めるボタンを作る

の二つを改善しました。

# 仕様決定
作りたいものは以下のイメージです。
アプリを起動すると二つのウィンドウが立ち上がります。
ひとつはエディタで、文章を直接打ち込んだりボタンからコマンドを打ち込んだりできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/1d92ab2e-dd20-89cd-5efc-9e4e4a1a2ca6.png)

ふたつめはpdfビューで、Edgeです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/e7c942e1-685f-a143-f92c-7611c44c5320.png)

前回同様、エディタとしての基本機能を備えたうえで、これらのボタンやEdgeを配置したいと思います。

# 設計
前回と異なりGUIだけでなくEdgeも操作しなければならないうえ、ボタン数が増えることでコード量も必然的に増えます。
そのため、アプリ捜査のメインをなすmain.pyのほかにstaticというディレクトリを作成し、その中に各モジュールを置くことにしました。
具体的には以下の5つです。

* buttons.py
* constants.py
* functions.py
* webdriver.py
* window.py

# プログラミング
## static
main.pyで使用するための関数や変数を定義します。
基本的にmain.pyも含めワイルドカードでimportを行い、変数を共有します。
そこそこの規模のプログラムでグローバル変数を使うことになりますが、長い名前の変数が多いので大丈夫だと思います。
### buttons.py
ポップアップウィンドウと、GUIの画面左に表示するボタンを定義します。
ポップアップウィンドウをどこに置くか迷いましたが、変数名にbuttonが入るのでここに置きました。
画面左のボタンたちについては、変数の末尾が「commands」のものはコマンドを、「buttons」のものはボタンの定義を表します。

```py
import PySimpleGUI as sg

FONT = ("HG正楷書体-PRO", 20)

# 名前を付けて保存
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
)

# ファイルを開く
files_browse_button = sg.Button(
    key="file_browse",
    button_type=sg.BUTTON_TYPE_BROWSE_FILES,
    pad=(0, 0),
    visible=False
)

# ポップアップ保存ウィンドウ
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,
    font=None,
    pad=None,
    k=None,
    metadata=None,
    button_type=sg.BUTTON_TYPE_SAVEAS_FILE,
    bind_return_key=True
)

# 数学記号など
symbol_commands = [
    "\\frac{}{}", "\\frac{d }{d }", "\\frac{\\partial }{\\partial }", "\\int_{}^{}",
    "\\sum\\limits_{}^{}",
"""\\begin{aligned}
\\left[
\\begin{array}{}

\\end{array}
\\right]
\\end{aligned}""",
"""\\begin{aligned}
\\left\\{
\\begin{array}{l}

\\end{array}
\\right.
\\end{aligned}""",
]
symbols = [
    "分数", "全微分", "偏微分", "積分", "総和", "行列", "方程式"
]
symbol_buttons = [
    sg.Button(
        symbol,
        key=symbol_commands[i],
        pad=(0, 0),
        font=FONT
    ) for i, symbol in enumerate(symbols)
]

# ギリシャ文字（小文字）
low_greek_letter_commands = [
    "\\alpha", "\\beta", "\\gamma", "\\delta", "\\epsilon", "\\zeta",
    "\\eta", "\\theta", "\\iota", "\\kappa", "\\lambda", "\\mu", "\\nu",
    "\\xi", "\\o", "\\pi", "\\rho", "\\sigma", "\\tau", "\\upsilon", "\\phi",
    "\\chi", "\\psi", "\\omega"
]
low_greek_letters = [
    "α", "β", "γ", "δ", "ε", "ζ",
    "η", "θ", "ι", "κ", "λ", "μ", "ν",
    "ξ", "ο", "π", "ρ", "σ", "τ", "υ", "φ",
    "χ", "ψ", "ω"
]
low_greek_buttons = [
    sg.Button(
        low_greek_letter,
        key=low_greek_letter_commands[i],
        pad=(0, 0),
        font=FONT
    ) for i, low_greek_letter in enumerate(low_greek_letters)
]

# ギリシャ文字（大文字）
high_greek_letter_commands = [
    "A", "B", "\\Gamma", "\\Delta", "E", "Z", "H", "\\Theta", "I",
    "K","\\Lambda", "M", "N", "\\Xi", "O", "\\Pi", "P", "\\Sigma",
    "T", "\\Upsilon", "\\Phi", "X", "\\Psi", "\\Omega"
]
high_greek_letters = [
    "Α", "Β", "Γ", "Δ", "Ε", "Ζ", "Η", "Θ", "Ι",
    "Κ", "Λ", "Μ", "Ν", "Ξ", "Ο", "Π", "Ρ", "Σ",
    "Τ", "Υ", "Φ", "X", "Ψ", "Ω"
]
high_greek_buttons = [
    sg.Button(
        high_greek_letter,
        key=high_greek_letter_commands[i],
        pad=(0, 0),
        font=FONT
    ) for i, high_greek_letter in enumerate(high_greek_letters)
]

# 装飾
decoration_commands = [
    "\\dot{}", "\\tilde{}", "\\sqrt[]{}"
]
decorations = [
    "ドット", "チルダ", "ルート"
]
decoration_buttons = [
    sg.Button(
        decoration,
        key=decoration_commands[i],
        pad=(0, 0),
        font=FONT
    ) for i, decoration in enumerate(decorations)
]

# 箇条書き
item_commands = [
"""\\begin{itemize}
\\item 
\\end{itemize}""",
"""\\begin{enumerate}
\\item 
\\end{enumerate}""",
"""\\begin{description}
\\item [] 
\\end{description}""", "\\item ", "\\item [] "
]
items = [
    "箇条書き", "番号付き箇条書き", "見出し付き箇条書き", "アイテム", "見出し付きアイテム"
]
item_buttons = [
    sg.Button(
        item,
        key=item_commands[i],
        pad=(0, 0),
        font=FONT
    ) for i, item in enumerate(items)
]

# 数式
equation_commands = [
"""\\begin{align}

\\end{align}""", "\\nonumber", "\\label{}", "\\ref{}"
]
equations = [
    "数式", "番号なし", "ラベル", "番号"
]
equation_buttons = [
    sg.Button(
        equation,
        key=equation_commands[i],
        pad=(0, 0),
        font=FONT
    ) for i, equation in enumerate(equations)
]
```

### 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"

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}"""

MAEZURI = """\\documentclass[uplatex]{jsarticle}

\\usepackage{graphicx}
\\userpackage{amsmath}
\\usepackage{here}
\\usepackage{multicol}
\\usepackage{caption}
% 余白指定
\\usepackage[top=2cm, bottom=2cm, left=1.5cm, right=1.5cm]{geometry}

% 図の名前のフォントやサイズ指定
\\captionsetup[figure]{font={bf,footnotesize}}

% 図の余白指定
\\setlength\intextsep{8pt}
\\setlength\textfloatsep{0pt}

% セクションのフォントサイズを通常の文字と同じにする
\\makeatletter
\\renewcommand{\\section}{\\@startsection{section}{1}{\\z@}%
{1.0\\Cvs \\@plus.0\\Cvs \\@minus.0\\Cvs}%
{.1\\Cvs \\@plus.0\\Cvs}%
{\\reset@font\\footnotesize\\mcfamily}}
\\makeatother

%%%%%%    TEXT START    %%%%%%
\\begin{document}
\\footnotesize
\\renewcommand{\\figurename}{Figure}
\\begin{center}
{\\scalebox{1.5}{研究タイトル}}\\\\
{\\scalebox{1.125}{Title of research}}\\\\
\\vskip\\baselineskip
{\\scalebox{1.125}{name}}\\\\
\\vskip\\baselineskip
/\end{center}
\/begin{abstract}

概要

\\end{abstract}
\\begin{multicols}{2}
\\section{. 緒言}
\\section{. 結言}
\\begin{thebibliography}{99}
\\bibitem{}
\\end{thebibliography}
\\end{multicols}
\\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から呼び出す関数を定義します。pdf_show関数のみ前回から変更になっていますが、他はほぼ同じです。

```py
import tkinter as tk
from copy import deepcopy
import os
from pathlib import Path
import re

import PySimpleGUI as sg

from static.buttons import *
from static.webdriver import *

def pdf_compile(file_path: str) -> None:
#     os.system(f"latexc {file_path}")
    file_path_w = Path(file_path)
    file_name = file_path_w.name.replace(".tex", "")
    file_path_ = str(file_path_w).replace("\\", "/")
    file_path_ = re.sub(r"\D:", f"/mnt/{file_path[0].lower()}", file_path)
    
    os.system(f"wsl uplatex {file_path_}")
    os.system(f"wsl dvipdf {file_name}")
    os.system("del *.aux, *.dvi, *.log")
#     os.system(f"move /y {file_name + '.pdf'}, {str(file_path_w).replace('.tex', '.pdf')}")
    os.replace(file_name + ".pdf", str(file_path_w).replace('.tex', '.pdf'))

# pdfファイルを開く
def pdf_show(window: sg.Window, file_path: str) -> None:
    pdf_file_path = file_path.replace(".tex", ".pdf")
    driver.get(pdf_file_path)

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

def paste(window: sg.Window, text: str) -> None:
    position = window["editor"].Widget.index("insert")
    window["editor"].Widget.insert(position, text)

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
        
        elif event == sg.WIN_CLOSED:
            is_cancel = True
            break
    
    popup_window.close()
    return is_cancel
```

### webdriver.py
Edgeを操作するdriverを定義します。

```py
from selenium import webdriver
from selenium.webdriver.edge.service import Service

EDGE_PATH ="~/msedgedriver.exe"

driver = webdriver.Edge(service=Service(EDGE_PATH))
```

### window.py
メインウィンドウを定義します。
前回はmain.pyの中に入れていた内容ですが、肥大化してしまったので分けました。

```py
import PySimpleGUI as sg

from static.buttons import *
from static.functions import *

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

b_length_div2 = int(len(low_greek_buttons) / 2)
buttons = [
    symbol_buttons,
    low_greek_buttons[:b_length_div2],
    low_greek_buttons[b_length_div2:],
    high_greek_buttons[:b_length_div2],
    high_greek_buttons[b_length_div2:],
    decoration_buttons,
    item_buttons[:3],
    item_buttons[3:],
    equation_buttons,
]

layout = [
    [
        sg.MenuBar(menu, key="menubar", pad=(0, 0))
    ],
    [
        sg.Column(buttons),
        sg.Multiline(key="editor", 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["editor"].Widget.configure(undo=True)
window["editor"].Widget.bind(
    "<Control-Key-Y>",
    lambda event, widget=window["editor"]: redo(event, widget)
)
```

## main.py
このアプリのメインとなる部分。
ウィンドウとEdgeの操作を行います。
画面左のボタンに対応する部分を追加したこと、pdfファイルを変数として保持する必要がなくなったこと、ページのアップダウンに関する処理が消えたことを除けば前回とほぼ同じです。
アプリを開いたときはEdgeには何も表示されず、ファイルを開いた際に同じ階層にpdfファイルがあれば表示します。また、上書き保存や名前を付けて保存のたびにコンパイルしなおして表示を更新します。

```py
import re
from pathlib import Path

import PySimpleGUI as sg
import pyperclip as cp

from static.buttons import *
from static.constants import *
from static.functions import *
from static.window import *

all_commands = low_greek_letter_commands + high_greek_letter_commands + symbol_commands\
               + decoration_commands + item_commands + equation_commands

file_path = None
saved = True
values = ""
now_page = 0
while True:
    event, values = window.read()#;print(event)

    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()
    if type(event) == str:
        if re.fullmatch(r"\D", event) or re.fullmatch(r"\d", event)\
           or event == BACKSPACE or event == DELETE:
            saved = False
            continue

    # ファイル
    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_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_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_show(window, file_path)
        
    elif 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

    # 編集
    elif 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
    
    # テンプレート
    elif event == "レポート":
        saved = False
        paste(window, REPORT)
    
    elif event == "前刷り":
        saved = False
        paste(window, MAEZURI)
    
    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)
        
    # 画面左のボタン
    elif event in all_commands:
        saved = False
        paste(window, event)

window.close()
driver.close()
```

# 完成
一通りの動作を確認しました。
一応バグは見当たりませんでしたが、動作が非常に重かったです。
ボタンを大量に追加したことが原因かもしれないと思いましたが、ボタン部分をすべてコメントアウトして実行しても大差なかったため関係ないと思います。
画面左に関する部分をmain.pyの一番後ろにもっていったり、eventが一文字もしくはBackSpaceもしくはDeleteのときはcontinueさせたりしましたがあまり効果はありませんでした。

原因が分かれば、また次回があるかもしれません。