Skip to content

Commit fdeca83

Browse files
feat: Add cross-platform MarkItDown GUI and multi-platform build workflow
Add a PySide6 desktop front-end (markitdown-gui/) with drag-and-drop, batch conversion, source/preview tabs, and saving. Include a PyInstaller spec and a GitHub Actions workflow that builds macOS (Intel + Apple Silicon), Windows, and Linux and attaches them to a release on gui-v* tags. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
1 parent e144e0a commit fdeca83

10 files changed

Lines changed: 892 additions & 0 deletions

File tree

.github/workflows/build-gui.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Build GUI
2+
3+
on:
4+
push:
5+
tags:
6+
- "gui-v*"
7+
workflow_dispatch: {}
8+
9+
jobs:
10+
build:
11+
name: Build (${{ matrix.asset }})
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
include:
17+
- os: macos-13 # Intel
18+
asset: macos-intel
19+
- os: macos-latest # Apple Silicon
20+
asset: macos-arm64
21+
- os: windows-latest
22+
asset: windows
23+
- os: ubuntu-latest
24+
asset: linux
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: actions/setup-python@v5
29+
with:
30+
python-version: "3.12"
31+
32+
- name: Install dependencies
33+
run: |
34+
python -m pip install --upgrade pip
35+
python -m pip install -r markitdown-gui/requirements.txt pyinstaller
36+
37+
- name: Build with PyInstaller
38+
working-directory: markitdown-gui
39+
run: pyinstaller build.spec --noconfirm --clean
40+
41+
- name: Package (macOS)
42+
if: runner.os == 'macOS'
43+
working-directory: markitdown-gui
44+
run: ditto -c -k --keepParent dist/MarkItDownGUI.app "MarkItDownGUI-${{ matrix.asset }}.zip"
45+
46+
- name: Package (Linux)
47+
if: runner.os == 'Linux'
48+
working-directory: markitdown-gui
49+
run: cd dist && zip -r "../MarkItDownGUI-${{ matrix.asset }}.zip" MarkItDownGUI
50+
51+
- name: Package (Windows)
52+
if: runner.os == 'Windows'
53+
working-directory: markitdown-gui
54+
shell: pwsh
55+
run: Compress-Archive -Path dist/MarkItDownGUI -DestinationPath "MarkItDownGUI-${{ matrix.asset }}.zip"
56+
57+
- name: Upload build artifact
58+
uses: actions/upload-artifact@v4
59+
with:
60+
name: MarkItDownGUI-${{ matrix.asset }}
61+
path: markitdown-gui/MarkItDownGUI-${{ matrix.asset }}.zip
62+
if-no-files-found: error
63+
64+
release:
65+
name: Publish release
66+
needs: build
67+
if: startsWith(github.ref, 'refs/tags/gui-v')
68+
runs-on: ubuntu-latest
69+
permissions:
70+
contents: write
71+
steps:
72+
- name: Download all build artifacts
73+
uses: actions/download-artifact@v4
74+
with:
75+
path: artifacts
76+
77+
- name: Create / update GitHub Release
78+
uses: softprops/action-gh-release@v2
79+
with:
80+
name: MarkItDown GUI ${{ github.ref_name }}
81+
generate_release_notes: true
82+
files: artifacts/**/*.zip

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,6 @@ cython_debug/
166166
src/.DS_Store
167167
.DS_Store
168168
.cursorrules
169+
170+
# MarkItDown GUI: keep the PyInstaller spec under version control (overrides *.spec above)
171+
!markitdown-gui/build.spec

markitdown-gui/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# MarkItDown GUI
2+
3+
A cross-platform desktop front-end for [MarkItDown](https://github.com/microsoft/markitdown).
4+
Convert PDF, Office documents, images, audio, HTML, URLs and more to Markdown — with
5+
drag-and-drop, batch conversion, a live preview, and one-click saving.
6+
7+
Built with [PySide6](https://doc.qt.io/qtforpython/) (Qt), so it runs on macOS, Windows, and Linux.
8+
9+
## Download
10+
11+
Pre-built apps for each platform are attached to the
12+
[Releases](../../releases) page:
13+
14+
| Platform | File |
15+
| --- | --- |
16+
| macOS | `MarkItDownGUI-macos.zip` (unzip → `MarkItDownGUI.app`) |
17+
| Windows | `MarkItDownGUI-windows.zip` (unzip → `MarkItDownGUI.exe`) |
18+
| Linux | `MarkItDownGUI-linux.zip` (unzip → run `MarkItDownGUI`) |
19+
20+
### Opening unsigned builds
21+
22+
These builds are **not code-signed** (that requires paid Apple/Microsoft developer
23+
accounts), so the OS may warn you the first time:
24+
25+
- **macOS:** right-click `MarkItDownGUI.app`**Open****Open**. Only needed once.
26+
- **Windows:** on the SmartScreen prompt click **More info****Run anyway**.
27+
28+
## Features
29+
30+
- Add files, paste a URL, or drag-and-drop files onto the window
31+
- Batch conversion with per-item status and a progress bar
32+
- **Markdown Source** and rendered **Preview** tabs
33+
- Save current as `.md`, save all to a folder, or copy to clipboard
34+
- Options (with hover help): enable 3rd-party plugins, keep data URIs
35+
36+
## Run from source
37+
38+
Requires Python 3.10+.
39+
40+
```bash
41+
cd markitdown-gui
42+
./run.sh # macOS / Linux: creates .venv and launches
43+
```
44+
45+
On Windows:
46+
47+
```bat
48+
cd markitdown-gui
49+
python -m venv .venv
50+
.venv\Scripts\python -m pip install -r requirements.txt
51+
.venv\Scripts\python app\main.py
52+
```
53+
54+
## Build a standalone executable
55+
56+
```bash
57+
cd markitdown-gui
58+
./build.sh # output in dist/
59+
```
60+
61+
`build.sh` uses `build.spec`, which bundles the data files MarkItDown needs
62+
(magika models, PDF CMaps, Office templates, etc.). PyInstaller cannot
63+
cross-compile — run it on each target OS, or use the GitHub Actions workflow
64+
at `.github/workflows/build-gui.yml`, which builds all three platforms and
65+
attaches them to a Release when you push a `gui-v*` tag.

markitdown-gui/app/converter.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Thin wrapper around the MarkItDown library."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from dataclasses import dataclass, field
7+
from typing import Optional
8+
9+
from markitdown import (
10+
MarkItDown,
11+
StreamInfo,
12+
MarkItDownException,
13+
)
14+
15+
16+
@dataclass
17+
class ConversionItem:
18+
"""A single input to convert: either a local file path or a URL."""
19+
20+
source: str
21+
is_url: bool = False
22+
23+
# Filled in after conversion.
24+
markdown: Optional[str] = None
25+
title: Optional[str] = None
26+
error: Optional[str] = None
27+
28+
@property
29+
def display_name(self) -> str:
30+
if self.is_url:
31+
return self.source
32+
return os.path.basename(self.source) or self.source
33+
34+
@property
35+
def succeeded(self) -> bool:
36+
return self.markdown is not None and self.error is None
37+
38+
def suggested_filename(self) -> str:
39+
if self.is_url:
40+
base = "".join(c if c.isalnum() else "_" for c in self.display_name)
41+
base = base.strip("_")[:60] or "output"
42+
else:
43+
base = os.path.splitext(os.path.basename(self.source))[0] or "output"
44+
return base + ".md"
45+
46+
47+
@dataclass
48+
class ConverterOptions:
49+
enable_plugins: bool = False
50+
keep_data_uris: bool = False
51+
52+
53+
def build_markitdown(options: ConverterOptions) -> MarkItDown:
54+
"""Construct a MarkItDown instance for the given options."""
55+
return MarkItDown(enable_plugins=options.enable_plugins)
56+
57+
58+
def convert_item(
59+
md: MarkItDown, item: ConversionItem, options: ConverterOptions
60+
) -> ConversionItem:
61+
"""Convert a single item in place, capturing markdown or an error string."""
62+
try:
63+
result = md.convert(item.source, keep_data_uris=options.keep_data_uris)
64+
item.markdown = result.markdown
65+
item.title = result.title
66+
item.error = None
67+
except MarkItDownException as exc:
68+
item.markdown = None
69+
item.error = _format_exception(exc)
70+
except FileNotFoundError:
71+
item.markdown = None
72+
item.error = "File not found."
73+
except Exception as exc: # noqa: BLE001 - surface anything to the UI
74+
item.markdown = None
75+
item.error = f"{type(exc).__name__}: {exc}"
76+
return item
77+
78+
79+
def _format_exception(exc: Exception) -> str:
80+
msg = str(exc).strip()
81+
if not msg:
82+
return type(exc).__name__
83+
# Keep it to the first few lines so the UI stays readable.
84+
lines = [ln for ln in msg.splitlines() if ln.strip()]
85+
return "\n".join(lines[:6])

0 commit comments

Comments
 (0)