Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ It focuses on fast multi-file conversion to Markdown with a modern Fluent-style

## Features

- Queue-based file workflow with drag and drop.
- Batch conversion with start, pause/resume, cancel, and progress feedback.
- Queue-based file workflow with drag and drop.
- Paste website URLs and convert article content to Markdown with the hosted Defuddle API.
- Batch conversion with start, pause/resume, cancel, and progress feedback.
- Results view with per-file selection and Markdown preview.
- Preview modes: rendered Markdown view and raw Markdown view.
- Save modes: export as one combined file or separate files.
Expand All @@ -23,10 +24,10 @@ It focuses on fast multi-file conversion to Markdown with a modern Fluent-style

Download prebuilt binaries from [Releases](https://github.com/imadreamerboy/markitdown-gui/releases), or run from source.

### Prerequisites
- Python `3.10+`
- `uv` (recommended)
### Prerequisites

- Python `3.10+`
- `uv` (recommended)

Install dependencies:

Expand All @@ -40,14 +41,23 @@ Alternative:
pip install -e .[dev]
```

### OCR Notes
### OCR Notes

- OCR is optional and disabled by default.
- Local OCR requires a system `tesseract` binary. Install it from the [official Tesseract project](https://github.com/tesseract-ocr/tesseract). If it is not on your `PATH`, set the executable path in Settings.
- Azure OCR requires an Azure Document Intelligence endpoint in Settings.
- Azure Document Intelligence pricing includes [500 free pages per month](https://azure.microsoft.com/en-us/products/ai-foundry/tools/document-intelligence#Pricing) at the time of writing.
- For API-key auth, set `AZURE_OCR_API_KEY`.
- If `AZURE_OCR_API_KEY` is not set, Azure OCR falls back to Azure identity credentials supported by `DefaultAzureCredential`.
- If `AZURE_OCR_API_KEY` is not set, Azure OCR falls back to Azure identity credentials supported by `DefaultAzureCredential`.

### Website URL Notes

- Website conversion uses the hosted [Defuddle](https://defuddle.md/) API.
- The app sends the pasted `http://` or `https://` URL to `https://defuddle.md/<url>` and stores the returned Markdown in the normal results view.
- Defuddle responses typically include YAML frontmatter metadata at the top when available.
- According to the [Defuddle Terms](https://defuddle.md/terms), unauthenticated requests are limited to `1,000` requests per month per IP address as of March 14, 2026.
- Because requests are sent directly from the desktop app, that free-tier limit applies to the user's own network IP.
- Website conversion requires an internet connection and depends on the external Defuddle service being available.

## Run the App

Expand Down
109 changes: 79 additions & 30 deletions markitdowngui/core/conversion.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
from __future__ import annotations

from dataclasses import dataclass
from itertools import islice
import os
from pathlib import Path

from PySide6.QtCore import QThread, Signal

IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tiff", ".webp"}
DOCINTEL_IMAGE_EXTENSIONS = {".bmp", ".jpeg", ".jpg", ".png", ".tiff"}
PDF_EXTENSION = ".pdf"
PDF_RENDER_SCALE = 3.0
LOCAL_OCR_TIMEOUT_SECONDS = 60
AZURE_OCR_API_KEY_ENV_VAR = "AZURE_OCR_API_KEY"
CONVERSION_ERROR_PREFIX = "Error converting "
BACKEND_AZURE = "azure"
BACKEND_LOCAL = "local"
BACKEND_NATIVE = "native"
from dataclasses import dataclass
from itertools import islice
import os
from pathlib import Path
from urllib.parse import quote

import requests

from PySide6.QtCore import QThread, Signal

from markitdowngui.core.input_sources import is_web_url

IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tiff", ".webp"}
DOCINTEL_IMAGE_EXTENSIONS = {".bmp", ".jpeg", ".jpg", ".png", ".tiff"}
PDF_EXTENSION = ".pdf"
PDF_RENDER_SCALE = 3.0
LOCAL_OCR_TIMEOUT_SECONDS = 60
DEFUDDLE_REQUEST_TIMEOUT_SECONDS = 30
DEFUDDLE_API_BASE_URL = "https://defuddle.md/"
AZURE_OCR_API_KEY_ENV_VAR = "AZURE_OCR_API_KEY"
CONVERSION_ERROR_PREFIX = "Error converting "
BACKEND_AZURE = "azure"
BACKEND_DEFUDDLE = "defuddle"
BACKEND_LOCAL = "local"
BACKEND_NATIVE = "native"


@dataclass(frozen=True)
Expand Down Expand Up @@ -144,13 +152,20 @@ def test_azure_ocr_connection(options: ConversionOptions) -> str:
return "api_key"


def convert_file_with_details(
file_path: str,
options: ConversionOptions | None = None,
) -> ConversionOutcome:
"""Convert a single file to Markdown text and report which backend produced it."""
effective_options = options or ConversionOptions()
extension = Path(file_path).suffix.lower()
def convert_file_with_details(
file_path: str,
options: ConversionOptions | None = None,
) -> ConversionOutcome:
"""Convert a single file to Markdown text and report which backend produced it."""
effective_options = options or ConversionOptions()

if is_web_url(file_path):
return ConversionOutcome(
markdown=_convert_url_with_defuddle(file_path),
backend=BACKEND_DEFUDDLE,
)

extension = Path(file_path).suffix.lower()

if not effective_options.ocr_enabled:
return ConversionOutcome(
Expand Down Expand Up @@ -259,10 +274,10 @@ def _convert_pdf_with_ocr_fallback(
)


def _convert_with_markitdown(
file_path: str,
options: ConversionOptions,
*,
def _convert_with_markitdown(
file_path: str,
options: ConversionOptions,
*,
use_docintel: bool = False,
) -> str:
# Delay heavy imports until conversion is requested.
Expand All @@ -274,8 +289,42 @@ def _convert_with_markitdown(
kwargs["docintel_credential"], _auth_method = _build_docintel_credential()

md = MarkItDown(**kwargs)
result = md.convert(file_path)
return result.text_content or ""
result = md.convert(file_path)
return result.text_content or ""


def _convert_url_with_defuddle(url: str) -> str:
request_url = _build_defuddle_request_url(url)

try:
response = requests.get(
request_url,
timeout=DEFUDDLE_REQUEST_TIMEOUT_SECONDS,
)
except requests.Timeout as exc:
raise RuntimeError(
"Website conversion timed out while waiting for the Defuddle service."
) from exc
except requests.RequestException as exc:
raise RuntimeError(
f"Website conversion failed to reach the Defuddle service: {exc}"
) from exc

if response.status_code == 429:
raise RuntimeError(
"Defuddle rate limit reached. The free tier allows up to 1,000 requests per month per IP."
)

if not response.ok:
message = response.text.strip()
raise RuntimeError(message or "Defuddle failed to convert the URL.")

return response.text.strip()


def _build_defuddle_request_url(url: str) -> str:
encoded_url = quote(url.strip(), safe="")
return f"{DEFUDDLE_API_BASE_URL}{encoded_url}"


def _convert_image_with_local_ocr(file_path: str, options: ConversionOptions) -> str:
Expand Down
50 changes: 50 additions & 0 deletions markitdowngui/core/input_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import re
from pathlib import Path, PureWindowsPath
from urllib.parse import unquote, urlparse

WEB_URL_SCHEMES = {"http", "https"}
UNSAFE_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")


def is_web_url(value: str) -> bool:
candidate = value.strip()
if not candidate:
return False
if any(ch.isspace() or ord(ch) < 32 for ch in candidate):
return False

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_web_url() is used as the primary validation gate for URL input, but it currently returns True for strings that contain whitespace in the URL path (e.g., "https://example.com/hello world"). Those inputs will later fail during HTTP request construction. Consider tightening validation (reject any whitespace / control chars) or normalizing to a properly encoded URL before enqueueing so the UI’s "Invalid URL" path catches these cases.

Suggested change
# Reject URLs containing any whitespace or control characters, as these
# will cause failures when constructing HTTP requests unless encoded.
if any(ch.isspace() or ord(ch) < 32 for ch in candidate):
return False

Copilot uses AI. Check for mistakes.
parsed = urlparse(candidate)
return parsed.scheme.lower() in WEB_URL_SCHEMES and bool(parsed.netloc)


def _source_path(source: str) -> Path | PureWindowsPath:
candidate = source.strip()
if "\\" in candidate:
return PureWindowsPath(candidate)
return Path(candidate)


def source_display_name(source: str) -> str:
return source.strip() if is_web_url(source) else _source_path(source).name or source


def source_output_stem(source: str) -> str:
if not is_web_url(source):
return _source_path(source).stem or "converted"

parsed = urlparse(source.strip())
path_parts = [part for part in parsed.path.split("/") if part]
slug = unquote(path_parts[-1]) if path_parts else ""
query = parsed.query.split("&", 1)[0] if parsed.query else ""

segments = [parsed.netloc]
if slug:
segments.append(slug)
elif query:
segments.append(query)

candidate = "-".join(segments)
sanitized = UNSAFE_FILENAME_CHARS.sub("-", candidate).strip("._-")
return sanitized or "website"
41 changes: 41 additions & 0 deletions markitdowngui/ui/components/url_input_bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from PySide6.QtCore import Signal
from PySide6.QtWidgets import QHBoxLayout, QWidget
from qfluentwidgets import LineEdit, PushButton


class UrlInputBar(QWidget):
url_submitted = Signal(str)

def __init__(self, translate, parent=None):
super().__init__(parent=parent)
self.translate = translate

layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)

self.url_edit = LineEdit(self)
self.url_edit.returnPressed.connect(self.submit_url)

self.submit_button = PushButton(self)
self.submit_button.clicked.connect(self.submit_url)

layout.addWidget(self.url_edit, 1)
layout.addWidget(self.submit_button)

self.retranslate_ui(translate)

def submit_url(self) -> None:
value = self.url_edit.text().strip()
if value:
self.url_submitted.emit(value)

def clear(self) -> None:
self.url_edit.clear()

def retranslate_ui(self, translate) -> None:
self.translate = translate
self.url_edit.setPlaceholderText(self.translate("home_url_placeholder"))
self.submit_button.setText(self.translate("home_add_url_button"))
38 changes: 29 additions & 9 deletions markitdowngui/ui/help_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,19 @@ def _build_ui(self) -> None:

repo_btn = HyperlinkButton()
repo_btn.setText(self.translate("help_open_repository"))
repo_btn.setIcon(FIF.GITHUB)
repo_btn.setUrl(QUrl("https://github.com/imadreamerboy/markitdown-gui"))
layout.addWidget(repo_btn, 0, Qt.AlignmentFlag.AlignLeft)

azure_pricing_btn = HyperlinkButton()
azure_pricing_btn.setText(self.translate("help_open_azure_ocr_pricing"))
azure_pricing_btn.setIcon(FIF.LINK)
repo_btn.setIcon(FIF.GITHUB)
repo_btn.setUrl(QUrl("https://github.com/imadreamerboy/markitdown-gui"))
layout.addWidget(repo_btn, 0, Qt.AlignmentFlag.AlignLeft)

defuddle_docs_btn = HyperlinkButton()
defuddle_docs_btn.setText(self.translate("help_open_defuddle_docs"))
defuddle_docs_btn.setIcon(FIF.LINK)
defuddle_docs_btn.setUrl(QUrl("https://defuddle.md/docs"))
layout.addWidget(defuddle_docs_btn, 0, Qt.AlignmentFlag.AlignLeft)

azure_pricing_btn = HyperlinkButton()
azure_pricing_btn.setText(self.translate("help_open_azure_ocr_pricing"))
azure_pricing_btn.setIcon(FIF.LINK)
azure_pricing_btn.setUrl(
QUrl(
"https://azure.microsoft.com/en-us/products/ai-foundry/tools/document-intelligence#Pricing"
Expand All @@ -96,8 +102,22 @@ def _build_ui(self) -> None:
tesseract_btn.setUrl(QUrl("https://github.com/tesseract-ocr/tesseract"))
layout.addWidget(tesseract_btn, 0, Qt.AlignmentFlag.AlignLeft)

layout.addWidget(TitleLabel(self.translate("help_faq_title")))

layout.addWidget(TitleLabel(self.translate("help_faq_title")))

layout.addWidget(
self._build_faq_card(
FIF.GLOBE,
"help_faq_defuddle_question",
"help_faq_defuddle_answer",
)
)
layout.addWidget(
self._build_faq_card(
FIF.GLOBE,
"help_faq_defuddle_limits_question",
"help_faq_defuddle_limits_answer",
)
)
layout.addWidget(
self._build_faq_card(
FIF.FOLDER,
Expand Down
Loading
Loading