feat: initial commit — markitdown converter

This commit is contained in:
2026-05-28 11:53:52 +00:00
commit 8b0e93bf69
5 changed files with 707 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
# System deps for markitdown (PDF, DOCX, etc.)
RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+12
View File
@@ -0,0 +1,12 @@
services:
markitdown:
build: .
container_name: markitdown
restart: unless-stopped
networks:
- web-net
networks:
web-net:
external: true
name: web_web-net
+70
View File
@@ -0,0 +1,70 @@
from fastapi import FastAPI, UploadFile, File, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from markitdown import MarkItDown
from markitdown._stream_info import StreamInfo
import io, os, mimetypes
app = FastAPI(title="MarkItDown Converter")
templates = Jinja2Templates(directory="templates")
# markitdown supports: PDF, DOCX, PPTX, XLSX, HTML, CSV, JSON, XML,
# images (with OCR via LLM), audio, ZIP, EPUB, YouTube URLs, Wikipedia, etc.
ALLOWED_EXTENSIONS = {
".pdf", ".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls",
".html", ".htm", ".csv", ".json", ".xml", ".epub",
".txt", ".md", ".rst", ".zip", ".msg",
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
".mp3", ".wav", ".m4a",
}
MAX_SIZE_MB = 50
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/convert")
async def convert(file: UploadFile = File(...)):
# Validate extension
_, ext = os.path.splitext(file.filename or "")
ext = ext.lower()
if ext not in ALLOWED_EXTENSIONS:
return JSONResponse(
{"error": f"Unsupported file type: '{ext}'. Supported: {', '.join(sorted(ALLOWED_EXTENSIONS))}"},
status_code=400
)
# Read into memory
data = await file.read()
size_mb = len(data) / (1024 * 1024)
if size_mb > MAX_SIZE_MB:
return JSONResponse(
{"error": f"File too large ({size_mb:.1f} MB). Max: {MAX_SIZE_MB} MB"},
status_code=413
)
try:
md = MarkItDown()
stream = io.BytesIO(data)
mime = mimetypes.types_map.get(ext, "application/octet-stream")
result = md.convert_stream(
stream,
stream_info=StreamInfo(
mimetype=mime,
extension=ext,
filename=file.filename,
)
)
markdown_text = result.text_content or ""
return JSONResponse({
"markdown": markdown_text,
"filename": file.filename,
"size_kb": round(len(data) / 1024, 1),
"chars": len(markdown_text),
})
except Exception as e:
return JSONResponse({"error": f"Conversion failed: {str(e)}"}, status_code=500)
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
markitdown[all]==0.1.1
jinja2==3.1.4
+604
View File
@@ -0,0 +1,604 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkItDown · carloselugo.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet">
<style>
/* ── Design tokens — match carloselugo.com ── */
:root {
--bg: #0a0a0a;
--bg-card: #111111;
--bg-hover: #161616;
--border: #1e1e1e;
--accent: #c8ff00;
--accent-2: #ff6b35;
--text: #e8e8e8;
--muted: #555;
--muted-2: #888;
--font-mono: 'Space Mono', monospace;
--font-sans: 'Syne', sans-serif;
--radius: 4px;
--max-w: 1100px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
min-height: 100vh;
}
/* Noise overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}
.container { max-width: var(--max-w); margin: 0 auto; padding: 0 24px; }
/* ── Header ── */
.site-header {
position: sticky; top: 0; z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--border);
background: rgba(10,10,10,0.85);
backdrop-filter: blur(12px);
}
.header-inner { display: flex; align-items: center; justify-content: space-between; }
.logo {
text-decoration: none;
font-family: var(--font-sans); font-weight: 800; font-size: 1.1rem;
letter-spacing: -0.02em; color: var(--text);
}
.logo-bracket { color: var(--accent); }
.logo span.logo-name { color: var(--text); }
.logo:hover .logo-name { color: var(--accent); }
.nav { display: flex; gap: 24px; align-items: center; }
.nav a {
font-family: var(--font-mono); font-size: 0.75rem;
color: var(--muted); text-decoration: none; letter-spacing: 0.03em;
transition: color 0.2s;
}
.nav a:hover { color: var(--accent); }
.nav-tag {
font-family: var(--font-mono); font-size: 0.65rem;
padding: 2px 8px; border: 1px solid var(--border);
border-radius: var(--radius); color: var(--muted);
}
/* ── Main layout ── */
main { padding: 64px 0 80px; }
.page-head { margin-bottom: 48px; }
.page-label {
font-family: var(--font-mono); font-size: 0.75rem;
color: var(--accent); letter-spacing: 0.1em; margin-bottom: 12px;
}
.page-title {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800; letter-spacing: -0.03em; line-height: 1.05;
margin-bottom: 12px;
}
.page-sub { font-size: 0.9rem; color: var(--muted); max-width: 480px; }
.accent-line {
display: inline-block; width: 40px; height: 3px;
background: var(--accent); margin-bottom: 16px;
}
/* ── Two-column layout ── */
.work-area {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
min-height: 520px;
}
@media (max-width: 768px) {
.work-area { grid-template-columns: 1fr; }
}
.panel {
background: var(--bg-card);
display: flex; flex-direction: column;
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
}
.panel-label {
font-family: var(--font-mono); font-size: 0.7rem;
color: var(--muted); letter-spacing: 0.1em;
}
.panel-meta {
font-family: var(--font-mono); font-size: 0.65rem; color: var(--muted);
}
.panel-body { flex: 1; padding: 24px; display: flex; flex-direction: column; }
/* ── Drop zone ── */
#drop-zone {
flex: 1;
border: 2px dashed var(--border);
border-radius: var(--radius);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 40px 24px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
position: relative;
min-height: 300px;
}
#drop-zone:hover, #drop-zone.drag-over {
border-color: var(--accent);
background: rgba(200,255,0,0.02);
}
#drop-zone.drag-over { border-style: solid; }
.drop-icon {
font-size: 2.5rem; margin-bottom: 16px;
filter: grayscale(1); opacity: 0.5;
transition: filter 0.2s, opacity 0.2s;
}
#drop-zone:hover .drop-icon,
#drop-zone.drag-over .drop-icon { filter: none; opacity: 1; }
.drop-title {
font-family: var(--font-mono); font-size: 0.75rem;
color: var(--muted-2); letter-spacing: 0.05em; margin-bottom: 8px;
}
.drop-hint { font-size: 0.78rem; color: var(--muted); margin-bottom: 20px; }
.drop-formats {
display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;
max-width: 320px;
}
.fmt-tag {
font-family: var(--font-mono); font-size: 0.6rem;
padding: 2px 6px; border: 1px solid var(--border);
border-radius: var(--radius); color: var(--muted);
}
#file-input { display: none; }
/* File loaded state */
.file-loaded {
display: none; flex-direction: column; align-items: center;
justify-content: center; text-align: center; padding: 40px 24px;
min-height: 300px;
}
.file-loaded.active { display: flex; }
#drop-zone.has-file { display: none; }
.file-icon { font-size: 3rem; margin-bottom: 12px; }
.file-name {
font-family: var(--font-mono); font-size: 0.75rem;
color: var(--accent); word-break: break-all; margin-bottom: 4px;
}
.file-size { font-size: 0.78rem; color: var(--muted); margin-bottom: 24px; }
.btn {
font-family: var(--font-mono); font-size: 0.7rem;
letter-spacing: 0.06em; padding: 10px 20px;
border-radius: var(--radius); cursor: pointer;
transition: all 0.15s; border: none;
}
.btn-primary {
background: var(--accent); color: #000;
}
.btn-primary:hover { filter: brightness(1.1); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost {
background: transparent; color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
.convert-actions { display: flex; gap: 8px; margin-top: 0; }
/* ── Progress / status ── */
#status-row {
display: none; align-items: center; gap: 8px;
padding: 12px 0; font-family: var(--font-mono); font-size: 0.7rem;
color: var(--muted);
}
#status-row.active { display: flex; }
.spinner {
width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Output panel ── */
.output-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; color: var(--muted);
}
.output-empty-icon { font-size: 2rem; opacity: 0.3; margin-bottom: 8px; }
.output-empty-text { font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.08em; }
#markdown-output {
display: none;
flex: 1; font-family: var(--font-mono); font-size: 0.78rem;
color: var(--text); line-height: 1.7; white-space: pre-wrap;
overflow-y: auto; max-height: 480px;
background: #0d0d0d; border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
resize: none; width: 100%;
}
#markdown-output.active { display: block; }
.output-actions {
display: none; gap: 8px; margin-top: 12px; flex-wrap: wrap;
}
.output-actions.active { display: flex; }
/* Output stats */
.output-stats {
display: none; gap: 20px; padding: 10px 0 4px;
font-family: var(--font-mono); font-size: 0.65rem; color: var(--muted);
}
.output-stats.active { display: flex; }
.stat span { color: var(--muted-2); }
/* Error state */
.error-box {
display: none;
background: rgba(255,107,53,0.06);
border: 1px solid rgba(255,107,53,0.3);
border-radius: var(--radius); padding: 12px 16px;
font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent-2);
}
.error-box.active { display: block; }
/* ── Supported formats info ── */
.formats-section { margin-top: 40px; padding-top: 40px; border-top: 1px solid var(--border); }
.formats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1px; background: var(--border);
border: 1px solid var(--border);
}
.fmt-group { background: var(--bg-card); padding: 20px; }
.fmt-group:hover { background: var(--bg-hover); }
.fmt-group-title {
font-family: var(--font-mono); font-size: 0.65rem;
color: var(--accent); letter-spacing: 0.1em; margin-bottom: 8px;
}
.fmt-list { font-size: 0.82rem; color: var(--muted); line-height: 1.9; }
/* ── Copy feedback ── */
.copy-feedback {
font-family: var(--font-mono); font-size: 0.65rem;
color: var(--accent); opacity: 0; transition: opacity 0.3s;
align-self: center;
}
.copy-feedback.show { opacity: 1; }
/* ── Footer ── */
footer {
border-top: 1px solid var(--border); padding: 24px 0; margin-top: 0;
}
.footer-inner {
display: flex; justify-content: space-between; align-items: center;
}
.footer-copy { font-family: var(--font-mono); font-size: 0.72rem; color: var(--muted); }
</style>
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a href="https://carloselugo.com" class="logo">
<span class="logo-bracket">[</span>
<span class="logo-name">clugo</span>
<span class="logo-bracket">]</span>
</a>
<nav class="nav">
<span class="nav-tag">tool</span>
<a href="https://carloselugo.com">home ↗</a>
</nav>
</div>
</header>
<main>
<div class="container">
<div class="page-head">
<div class="page-label">// document converter</div>
<div class="accent-line"></div>
<h1 class="page-title">MarkItDown</h1>
<p class="page-sub">Drop any document. Get clean Markdown. PDF, DOCX, PPTX, XLSX, HTML, and more.</p>
</div>
<div class="work-area">
<!-- INPUT PANEL -->
<div class="panel">
<div class="panel-header">
<span class="panel-label">INPUT</span>
<span class="panel-meta" id="file-meta">no file selected</span>
</div>
<div class="panel-body">
<!-- Drop zone -->
<div id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="drop-icon">📄</div>
<div class="drop-title">DROP FILE HERE</div>
<div class="drop-hint">or click to browse</div>
<div class="drop-formats">
<span class="fmt-tag">pdf</span>
<span class="fmt-tag">docx</span>
<span class="fmt-tag">pptx</span>
<span class="fmt-tag">xlsx</span>
<span class="fmt-tag">html</span>
<span class="fmt-tag">csv</span>
<span class="fmt-tag">epub</span>
<span class="fmt-tag">json</span>
<span class="fmt-tag">xml</span>
<span class="fmt-tag">+ more</span>
</div>
</div>
<input type="file" id="file-input" accept="*/*">
<!-- File loaded state -->
<div class="file-loaded" id="file-loaded">
<div class="file-icon" id="file-icon">📄</div>
<div class="file-name" id="file-name-display"></div>
<div class="file-size" id="file-size-display"></div>
<div class="convert-actions">
<button class="btn btn-primary" id="convert-btn" onclick="convertFile()">▶ CONVERT</button>
<button class="btn btn-ghost" onclick="clearFile()">✕ CLEAR</button>
</div>
</div>
<!-- Status -->
<div id="status-row">
<div class="spinner"></div>
<span id="status-text">Converting...</span>
</div>
<!-- Error -->
<div class="error-box" id="error-box"></div>
</div>
</div>
<!-- OUTPUT PANEL -->
<div class="panel">
<div class="panel-header">
<span class="panel-label">OUTPUT · MARKDOWN</span>
<span class="panel-meta" id="output-meta"></span>
</div>
<div class="panel-body">
<div class="output-empty" id="output-empty">
<div class="output-empty-icon"></div>
<div class="output-empty-text">MARKDOWN WILL APPEAR HERE</div>
</div>
<div class="output-stats" id="output-stats">
<div class="stat"><span>chars</span> <strong id="stat-chars">0</strong></div>
<div class="stat"><span>lines</span> <strong id="stat-lines">0</strong></div>
<div class="stat"><span>words</span> <strong id="stat-words">0</strong></div>
</div>
<textarea id="markdown-output" readonly spellcheck="false"></textarea>
<div class="output-actions" id="output-actions">
<button class="btn btn-primary" onclick="downloadMarkdown()">↓ DOWNLOAD .md</button>
<button class="btn btn-ghost" id="copy-btn" onclick="copyMarkdown()">⎘ COPY</button>
<span class="copy-feedback" id="copy-feedback">copied!</span>
</div>
</div>
</div>
</div>
<!-- Supported formats -->
<div class="formats-section">
<div class="accent-line"></div>
<h2 style="font-size:1.1rem;font-weight:800;letter-spacing:-0.02em;margin-bottom:24px;">Supported Formats</h2>
<div class="formats-grid">
<div class="fmt-group">
<div class="fmt-group-title">DOCUMENTS</div>
<div class="fmt-list">PDF<br>DOCX / DOC<br>PPTX / PPT<br>XLSX / XLS<br>EPUB<br>MSG (Outlook)</div>
</div>
<div class="fmt-group">
<div class="fmt-group-title">WEB / DATA</div>
<div class="fmt-list">HTML / HTM<br>CSV<br>JSON<br>XML<br>RSS / Atom<br>Plain text</div>
</div>
<div class="fmt-group">
<div class="fmt-group-title">IMAGES</div>
<div class="fmt-list">PNG<br>JPG / JPEG<br>GIF<br>WEBP<br>BMP<br>(OCR via AI)</div>
</div>
<div class="fmt-group">
<div class="fmt-group-title">AUDIO</div>
<div class="fmt-list">MP3<br>WAV<br>M4A<br>(Transcription via AI)</div>
</div>
<div class="fmt-group">
<div class="fmt-group-title">ARCHIVES</div>
<div class="fmt-list">ZIP<br>(converts each file inside)</div>
</div>
</div>
</div>
</div>
</main>
<footer>
<div class="container footer-inner">
<span class="footer-copy">© <script>document.write(new Date().getFullYear())</script> Carlos Lugo</span>
<span class="footer-copy">powered by markitdown</span>
</div>
</footer>
<script>
let selectedFile = null;
const ICONS = {
pdf: '📕', docx: '📘', doc: '📘', pptx: '📙', ppt: '📙',
xlsx: '📗', xls: '📗', html: '🌐', htm: '🌐', csv: '📊',
json: '🔧', xml: '📄', epub: '📚', txt: '📝', md: '📝',
zip: '📦', msg: '✉️',
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', webp: '🖼️',
mp3: '🎵', wav: '🎵', m4a: '🎵',
};
function getIcon(filename) {
const ext = (filename || '').split('.').pop().toLowerCase();
return ICONS[ext] || '📄';
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/(1024*1024)).toFixed(1) + ' MB';
}
// Drag & drop
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f) setFile(f);
});
document.getElementById('file-input').addEventListener('change', e => {
if (e.target.files[0]) setFile(e.target.files[0]);
});
function setFile(f) {
selectedFile = f;
const ext = f.name.split('.').pop().toLowerCase();
document.getElementById('file-icon').textContent = getIcon(f.name);
document.getElementById('file-name-display').textContent = f.name;
document.getElementById('file-size-display').textContent = formatSize(f.size) + ' · .' + ext;
document.getElementById('file-meta').textContent = f.name;
dropZone.classList.add('has-file');
document.getElementById('file-loaded').classList.add('active');
clearOutput();
hideError();
}
function clearFile() {
selectedFile = null;
document.getElementById('file-input').value = '';
document.getElementById('file-meta').textContent = 'no file selected';
dropZone.classList.remove('has-file');
document.getElementById('file-loaded').classList.remove('active');
clearOutput();
hideError();
}
function clearOutput() {
document.getElementById('output-empty').style.display = 'flex';
document.getElementById('markdown-output').classList.remove('active');
document.getElementById('output-actions').classList.remove('active');
document.getElementById('output-stats').classList.remove('active');
document.getElementById('output-meta').textContent = '';
document.getElementById('markdown-output').value = '';
}
function showError(msg) {
const el = document.getElementById('error-box');
el.textContent = '✗ ' + msg;
el.classList.add('active');
}
function hideError() {
document.getElementById('error-box').classList.remove('active');
}
async function convertFile() {
if (!selectedFile) return;
hideError();
document.getElementById('status-row').classList.add('active');
document.getElementById('status-text').textContent = 'Converting...';
document.getElementById('convert-btn').disabled = true;
clearOutput();
const form = new FormData();
form.append('file', selectedFile);
try {
const res = await fetch('/convert', { method: 'POST', body: form });
const data = await res.json();
if (!res.ok || data.error) {
showError(data.error || 'Unknown error');
return;
}
// Show output
const md = data.markdown || '';
document.getElementById('markdown-output').value = md;
document.getElementById('markdown-output').classList.add('active');
document.getElementById('output-actions').classList.add('active');
document.getElementById('output-empty').style.display = 'none';
// Stats
const lines = md.split('\n').length;
const words = md.trim() ? md.trim().split(/\s+/).length : 0;
document.getElementById('stat-chars').textContent = md.length.toLocaleString();
document.getElementById('stat-lines').textContent = lines.toLocaleString();
document.getElementById('stat-words').textContent = words.toLocaleString();
document.getElementById('output-stats').classList.add('active');
document.getElementById('output-meta').textContent = `${md.length.toLocaleString()} chars`;
} catch(e) {
showError('Request failed: ' + e.message);
} finally {
document.getElementById('status-row').classList.remove('active');
document.getElementById('convert-btn').disabled = false;
}
}
function downloadMarkdown() {
const md = document.getElementById('markdown-output').value;
const base = (selectedFile?.name || 'document').replace(/\.[^.]+$/, '');
const blob = new Blob([md], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = base + '.md';
a.click();
URL.revokeObjectURL(a.href);
}
async function copyMarkdown() {
const md = document.getElementById('markdown-output').value;
try {
await navigator.clipboard.writeText(md);
} catch {
document.getElementById('markdown-output').select();
document.execCommand('copy');
}
const fb = document.getElementById('copy-feedback');
fb.classList.add('show');
setTimeout(() => fb.classList.remove('show'), 2000);
}
// Allow editing the output textarea
document.getElementById('markdown-output').addEventListener('focus', function() {
this.removeAttribute('readonly');
});
</script>
</body>
</html>