# pip install markdown
# pip install tkhtmlview
# pip install Pillow
"""
Markdown Viewerアプリケーション
概要:
このモジュールは、MarkdownファイルをHTML形式で表示するためのシンプルなTkinterアプリケーションを提供します。
画像の動的なリサイズ、フォントサイズの調整、INIファイルによる設定の永続化などの機能を備えています。
詳細説明:
コマンドライン引数でファイルパスを指定して直接ファイルを開くことも、
アプリケーション起動後にメニューからファイルを開くことも可能です。
画像は指定された幅に自動的にリサイズされ、一時ディレクトリに保存されます。
終了時には一時ディレクトリがクリーンアップされ、現在のウィンドウの状態や設定がINIファイルに保存されます。
関連リンク:
:doc:`mdview_usage`
"""
import tkinter as tk
from tkinter import filedialog, simpledialog
import markdown
from tkhtmlview import HTMLText
import argparse
import os
import re
from PIL import Image
import time
import shutil
[ドキュメント]
def read_ini_file(filename):
"""INIファイルから設定を読み込む
INIファイルからキーと値のペアを読み込み、辞書として返します。
ファイルが存在しない場合は空の辞書を返します。
:param filename: (str) 読み込むINIファイルのパス。
:returns: (dict) 読み込まれた設定の辞書。
"""
settings = {}
if not os.path.exists(filename):
return settings
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
settings[key.strip()] = value.strip()
return settings
[ドキュメント]
def write_ini_file(filename, settings):
"""設定をINIファイルに書き込む
指定された辞書の内容をINIファイル形式で書き込みます。
各キーと値は「キー=値」の形式で1行に書き出されます。
:param filename: (str) 書き込むINIファイルのパス。
:param settings: (dict) 書き込む設定を含む辞書。
:returns: (None)
"""
with open(filename, 'w', encoding='utf-8') as f:
for key, value in settings.items():
f.write(f"{key}={value}\n")
[ドキュメント]
class MarkdownViewerApp:
"""MarkdownファイルをHTML形式で表示するTkinterアプリケーション
INIファイルからの設定読み込み、画像の動的なリサイズ、フォントサイズ調整などの機能を提供します。
アプリケーションのウィンドウ作成、メニューバーの構築、ファイルの読み込みと表示を行います。
"""
def __init__(self, master, md_filename=None, image_width=None, font_size=None):
"""MarkdownViewerAppのコンストラクタ
アプリケーションの初期設定を行い、UIを構築します。
:param master: (tk.Tk) Tkinterのルートウィンドウまたは親ウィジェット。
:param md_filename: (str, optional) アプリケーション起動時に開くMarkdownファイルのパス。デフォルトはNone。
:param image_width: (int, optional) 画像の最大表示幅 (ピクセル)。指定がない場合はINI設定またはデフォルト値を使用。
:param font_size: (int, optional) 基準フォントサイズ (ピクセル)。指定がない場合はINI設定またはデフォルト値を使用。
"""
self.master = master
self.md_filename = md_filename
self.ini_filename = "mdview.ini"
self.temp_image_dir = os.path.join(os.getcwd(), ".mdview_temp")
os.makedirs(self.temp_image_dir, exist_ok=True)
self.load_settings()
# コマンドライン引数が優先
self.image_width = image_width if image_width is not None else self.image_width
self.font_size = font_size if font_size is not None else self.font_size
master.title("Markdown Viewer")
if hasattr(self, 'geometry'):
master.geometry(self.geometry)
else:
master.geometry("800x600")
self.html_widget = HTMLText(master)
self.html_widget.pack(fill="both", expand=True, padx=10, pady=10)
self.create_menu()
if self.md_filename:
self.load_markdown_file(self.md_filename)
else:
html_text = f'<div style="font-size: {self.font_size}px;"><p>コマンドライン引数でファイル名を指定するか、メニューからファイルを開いてください。</p></div>'
self.html_widget.set_html(html_text)
self.master.protocol("WM_DELETE_WINDOW", self.on_closing)
[ドキュメント]
def load_settings(self):
"""INIファイルからアプリケーション設定を読み込む
`mdview.ini`ファイルからウィンドウのジオメトリ、最終開いたディレクトリ、
画像の表示幅、基準フォントサイズを読み込み、インスタンス変数に設定します。
設定が見つからない場合はデフォルト値を使用します。
:returns: (None)
"""
settings = read_ini_file(self.ini_filename)
self.geometry = settings.get("geometry")
self.last_directory = settings.get("last_directory", os.getcwd())
self.image_width = int(settings.get("image_width", "800"))
self.font_size = int(settings.get("font_size", "12"))
[ドキュメント]
def on_closing(self):
"""アプリケーション終了時の処理を実行する
現在のウィンドウのジオメトリ、最後に開いたディレクトリ、画像幅、フォントサイズをINIファイルに保存します。
また、一時画像を保存したディレクトリを削除してからアプリケーションを終了します。
:returns: (None)
"""
settings = {
"geometry": self.master.winfo_geometry(),
"last_directory": os.path.dirname(self.md_filename) if self.md_filename and os.path.dirname(self.md_filename) else self.last_directory,
"image_width": str(self.image_width),
"font_size": str(self.font_size)
}
write_ini_file(self.ini_filename, settings)
if os.path.exists(self.temp_image_dir):
shutil.rmtree(self.temp_image_dir, ignore_errors=True)
self.master.destroy()
self.master.quit()
[ドキュメント]
def reload_file(self):
"""現在開いているMarkdownファイルを再読み込みする
`md_filename`が設定されている場合、そのファイルを再度読み込み表示を更新します。
:returns: (None)
"""
if self.md_filename:
self.load_markdown_file(self.md_filename)
else:
print("ファイルが開かれていません。")
[ドキュメント]
def open_image_width_dialog(self):
"""画像表示幅を設定するためのダイアログを開く
ユーザーから新しい画像表示幅を整数値で入力させます。
入力が有効な場合は`image_width`を更新し、Markdownファイルが
開かれている場合は再読み込みして変更を適用します。
:returns: (None)
"""
new_width = simpledialog.askinteger(
"設定", "画像の表示幅を入力してください (ピクセル)",
initialvalue=self.image_width,
parent=self.master
)
if new_width is not None and new_width > 0:
self.image_width = new_width
if self.md_filename:
self.load_markdown_file(self.md_filename)
[ドキュメント]
def open_font_size_dialog(self):
"""基準フォントサイズを設定するためのダイアログを開く
ユーザーから新しい基準フォントサイズを整数値で入力させます。
入力が有効な場合は`font_size`を更新し、Markdownファイルが
開かれている場合は再読み込みして変更を適用します。
ファイルが開かれていない場合は初期表示テキストのフォントサイズを変更します。
:returns: (None)
"""
new_size = simpledialog.askinteger(
"設定", "基準フォントサイズを入力してください (ピクセル)",
initialvalue=self.font_size,
parent=self.master,
minvalue=8,
maxvalue=32
)
if new_size is not None:
self.font_size = new_size
if self.md_filename:
self.load_markdown_file(self.md_filename)
else:
html_text = f'<div style="font-size: {self.font_size}px;"><p>コマンドライン引数でファイル名を指定するか、メニューからファイルを開いてください。</p></div>'
self.html_widget.set_html(html_text)
[ドキュメント]
def load_markdown_file(self, file_path):
"""指定されたMarkdownファイルを読み込み、HTMLに変換して表示する
ファイルを読み込み、`process_images`で画像を処理した後、
MarkdownをHTMLに変換します。見出しタグには動的にフォントサイズを適用し、
HTML全体を基準フォントサイズで表示します。
:param file_path: (str) 読み込むMarkdownファイルのパス。
:returns: (None)
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
markdown_text = f.read()
markdown_text = self.process_images(markdown_text, os.path.dirname(file_path))
html_text = markdown.markdown(markdown_text, extensions=['fenced_code', 'tables'])
# 見出しタグにフォントサイズを直接適用
html_text = re.sub(r'<h(\d)>(.*?)</h\1>', self.replace_heading_size, html_text)
# HTMLテキスト全体を<div>で囲み、フォントサイズを適用する
html_with_style = f'<div style="font-size: {self.font_size}px;">{html_text}</div>'
self.html_widget.set_html(html_with_style)
self.md_filename = file_path
self.last_directory = os.path.dirname(file_path)
except FileNotFoundError:
html_text = f'<div style="font-size: {self.font_size}px;"><p>エラー: ファイル "{file_path}" が見つかりません。</p></div>'
self.html_widget.set_html(html_text)
except Exception as e:
html_text = f'<div style="font-size: {self.font_size}px;"><p>エラーが発生しました: {e}</p></div>'
self.html_widget.set_html(html_text)
[ドキュメント]
def replace_heading_size(self, match):
"""見出しタグのサイズを基準フォントサイズに基づいて計算し、スタイルを適用する
正規表現のマッチオブジェクトから見出しレベル(h1, h2など)と内容を取得し、
基準フォントサイズに応じた計算で新たなフォントサイズを設定したHTMLタグを返します。
:param match: (re.Match) 見出しタグにマッチした正規表現オブジェクト。
:returns: (str) スタイルが適用された見出しタグのHTML文字列。
"""
level = int(match.group(1))
content = match.group(2)
# h1, h2, h3... に応じてフォントサイズを計算
# 例: h1=基準*2, h2=基準*1.5, h3=基準*1.2
size_factor = {1: 2.0, 2: 1.5, 3: 1.2, 4: 1.1, 5: 1.0, 6: 0.9}
new_size = int(self.font_size * size_factor.get(level, 1.0))
return f'<h{level} style="font-size: {new_size}px;">{content}</h{level}>'
[ドキュメント]
def process_images(self, markdown_text, base_dir):
"""Markdownテキスト内の画像を処理し、必要に応じてリサイズして一時ファイルに保存する
Markdownの画像構文(``)を解析し、指定された`image_width`を超える画像を
一時ディレクトリにリサイズして保存します。Markdown内の画像パスを一時ファイルのパスに書き換え、
元の画像のアスペクト比を維持します。
:param markdown_text: (str) 処理対象のMarkdownテキスト。
:param base_dir: (str) Markdownファイルが存在するディレクトリのパス。相対パス解決の基準となる。
:returns: (str) 処理されたMarkdownテキスト。
"""
def replace_image_path(match):
alt_text = match.group(1)
original_path = match.group(2)
img_path_abs = os.path.join(base_dir, original_path)
if not os.path.exists(img_path_abs):
return match.group(0) # ファイルが存在しない場合は元のまま返す
try:
img = Image.open(img_path_abs)
if img.width > self.image_width:
ratio = self.image_width / img.width
new_height = int(img.height * ratio)
resized_img = img.resize((self.image_width, new_height), Image.Resampling.LANCZOS)
timestamp = int(time.time() * 1000)
temp_filename = f"resized_{timestamp}_{os.path.basename(original_path)}"
temp_path = os.path.join(self.temp_image_dir, temp_filename)
resized_img.save(temp_path)
return f''
return match.group(0) # リサイズが不要な場合は元のまま返す
except Exception as e:
# 画像処理中にエラーが発生した場合、元のパスをそのまま返す
return match.group(0)
# Markdownの画像構文  を検出して処理
markdown_text = re.sub(r'!\[(.*?)\]\((.*?)\)', replace_image_path, markdown_text)
return markdown_text
[ドキュメント]
def open_file(self):
"""ファイル選択ダイアログを開き、Markdownファイルを選択して表示する
ユーザーにファイルダイアログを通してMarkdownファイルを選択させ、
選択されたファイルを`load_markdown_file`メソッドで読み込み、表示を更新します。
:returns: (None)
"""
file_path = filedialog.askopenfilename(
defaultextension=".md",
filetypes=[("Markdown files", "*.md"), ("All files", "*.*")],
initialdir=self.last_directory
)
if file_path:
self.load_markdown_file(file_path)
[ドキュメント]
def parse_arguments():
"""コマンドライン引数を解析する
`argparse`モジュールを使用して、Markdownファイルパス、画像幅、
基準フォントサイズなどのコマンドライン引数を解析します。
:returns: (argparse.Namespace) 解析された引数を含むオブジェクト。
"""
parser = argparse.ArgumentParser(description="Markdown Viewer")
parser.add_argument('file', nargs='?', help='表示するMarkdownファイル名')
parser.add_argument('-w', '--width', type=int, help='画像の表示幅 (ピクセル)')
parser.add_argument('-f', '--font', type=int, help='基準フォントサイズ (ピクセル)')
return parser.parse_args()
if __name__ == "__main__":
args = parse_arguments()
root = tk.Tk()
app = MarkdownViewerApp(root, md_filename=args.file, image_width=args.width, font_size=args.font)
root.mainloop()