Spaces:
Paused
Paused
File size: 23,141 Bytes
0a7e835 6efba54 0a7e835 48891a9 0a7e835 48891a9 0a7e835 48891a9 0a7e835 48891a9 0a7e835 ff9cf4e 0a7e835 2d24a3b 0a7e835 c383f9a 48891a9 2d24a3b ff9cf4e 2d24a3b c383f9a 0a7e835 c383f9a 0a7e835 c383f9a 0a7e835 c383f9a 0a7e835 c383f9a 0a7e835 48891a9 0a7e835 2d24a3b 0a7e835 48891a9 0a7e835 48891a9 0a7e835 312a6a5 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 |
from flask import Flask, request, render_template_string, send_file, jsonify
import markdown
import imgkit
import os
import traceback
from io import BytesIO
import re
import base64
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
from pygments.styles import get_all_styles
import uuid
app = Flask(__name__)
TEMP_DIR = os.path.join(os.getcwd(), "temp")
os.makedirs(TEMP_DIR, exist_ok=True)
# --- FORMAT PARSING AND DETECTION (Unchanged) ---
def parse_repo2markdown(text):
components = []
pattern = re.compile(r'### File: (.*?)\n([\s\S]*?)(?=\n### File:|\Z)', re.MULTILINE)
first_match = pattern.search(text)
if first_match:
intro_text = text[:first_match.start()].strip()
if intro_text:
components.append({'type': 'intro', 'filename': 'Introduction', 'content': intro_text, 'is_code_block': False, 'language': ''})
for match in pattern.finditer(text):
filename = match.group(1).strip()
raw_content = match.group(2).strip()
code_match = re.search(r'^```(\w*)\s*\n([\s\S]*?)\s*```$', raw_content, re.DOTALL)
if code_match:
components.append({'type': 'file', 'filename': filename, 'content': code_match.group(2).strip(), 'is_code_block': True, 'language': code_match.group(1)})
else:
components.append({'type': 'file', 'filename': filename, 'content': raw_content, 'is_code_block': False, 'language': ''})
return components
def parse_standard_readme(text):
components = []
parts = re.split(r'^(## .*?)$', text, flags=re.MULTILINE)
intro_content = parts[0].strip()
if intro_content:
components.append({'type': 'intro', 'filename': 'Introduction', 'content': intro_content})
for i in range(1, len(parts), 2):
components.append({'type': 'section', 'filename': parts[i].replace('##', '').strip(), 'content': parts[i+1].strip()})
return components
def parse_changelog(text):
components = []
parts = re.split(r'^(## \[\d+\.\d+\.\d+.*?\].*?)$', text, flags=re.MULTILINE)
intro_content = parts[0].strip()
if intro_content:
components.append({'type': 'intro', 'filename': 'Changelog Header', 'content': intro_content})
for i in range(1, len(parts), 2):
components.append({'type': 'version', 'filename': parts[i].replace('##', '').strip(), 'content': parts[i+1].strip()})
return components
@app.route('/parse', methods=['POST'])
def parse_endpoint():
text = request.form.get('markdown_text', '')
if 'markdown_file' in request.files and request.files['markdown_file'].filename != '':
text = request.files['markdown_file'].read().decode('utf-8')
if not text: return jsonify({'error': 'No text or file provided.'}), 400
try:
if "## File Structure" in text and "### File:" in text:
format_name, components = "Repo2Markdown", parse_repo2markdown(text)
elif re.search(r'^## \[\d+\.\d+\.\d+.*?\].*?$', text, flags=re.MULTILINE):
format_name, components = "Changelog", parse_changelog(text)
elif text.strip().startswith("#") and re.search(r'^## ', text, flags=re.MULTILINE):
format_name, components = "Standard README", parse_standard_readme(text)
else:
format_name, components = "Unknown", [{'type': 'text', 'filename': 'Full Text', 'content': text}]
return jsonify({'format': format_name, 'components': components})
except Exception as e:
return jsonify({'error': f'Failed to parse: {e}'}), 500
# --- HTML & PNG BUILDER (Unchanged) ---
def build_full_html(markdown_text, styles, include_fontawesome):
wrapper_id = "#output-wrapper"
font_family = styles.get('font_family', "'Arial', sans-serif")
google_font_name = font_family.split(',')[0].strip("'\"")
google_font_link = ""
if " " in google_font_name and google_font_name not in ["Times New Roman", "Courier New"]:
google_font_link = f'<link href="https://fonts.googleapis.com/css2?family={google_font_name.replace(" ", "+")}:wght@400;700&display=swap" rel="stylesheet">'
highlight_theme = styles.get('highlight_theme', 'default')
pygments_css = ""
if highlight_theme != 'none':
formatter = HtmlFormatter(style=highlight_theme, cssclass="codehilite")
pygments_css = formatter.get_style_defs(f' {wrapper_id}')
scoped_css = f"""
{wrapper_id} {{
font-family: {font_family}; font-size: {styles.get('font_size', '16')}px;
color: {styles.get('text_color', '#333')}; background-color: {styles.get('background_color', '#fff')};
}}
{wrapper_id} table {{ border-collapse: collapse; width: 100%; }}
{wrapper_id} th, {wrapper_id} td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
{wrapper_id} th {{ background-color: #f2f2f2; }}
{wrapper_id} img {{ max-width: 100%; height: auto; }}
{wrapper_id} pre {{ padding: {styles.get('code_padding', '15')}px; border-radius: 5px; white-space: pre-wrap; word-wrap: break-word; }}
{wrapper_id} h1, {wrapper_id} h2, {wrapper_id} h3 {{ border-bottom: 1px solid #eee; padding-bottom: 5px; margin-top: 1.5em; }}
{wrapper_id} :not(pre) > code {{ font-family: 'Courier New', monospace; background-color: #eef; padding: .2em .4em; border-radius: 3px; }}
{pygments_css} {styles.get('custom_css', '')}
"""
md_extensions = ['fenced_code', 'tables', 'codehilite']
html_content = markdown.markdown(markdown_text, extensions=md_extensions, extension_configs={'codehilite': {'css_class': 'codehilite'}})
final_html_body = f'<div id="output-wrapper">{html_content}</div>'
fontawesome_link = '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">' if include_fontawesome else ""
full_html = f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8">{google_font_link}{fontawesome_link}<style>
#ouput-wrapper {{ background-color: {styles.get('background_color', '#fff')}; padding: 25px; display: inline-block;}}
{scoped_css}
</style></head><body>{final_html_body}</body></html>"""
return full_html
# --- API ENDPOINT for Conversion (Unchanged) ---
@app.route('/convert', methods=['POST'])
def convert_endpoint():
data = request.json
temp_html_path = None
try:
full_html = build_full_html(
markdown_text=data.get('markdown_text', ''),
styles=data.get('styles', {}),
include_fontawesome=data.get('include_fontawesome', False)
)
# Use xvfb, which is required for stable headless rendering.
options = {
"quiet": "",
'encoding': "UTF-8",
'xvfb': ''
}
temp_html_path = os.path.join(TEMP_DIR, f"{uuid.uuid4()}.html")
with open(temp_html_path, "w", encoding="utf-8") as f:
f.write(full_html)
if data.get('download', False):
download_type = data.get('download_type', 'png')
if download_type == 'html':
return send_file(BytesIO(full_html.encode("utf-8")), as_attachment=True, download_name="output.html", mimetype="text/html")
else:
png_bytes = imgkit.from_file(temp_html_path, False, options=options)
return send_file(BytesIO(png_bytes), as_attachment=True, download_name="output.png", mimetype="image/png")
else:
png_bytes = imgkit.from_file(temp_html_path, False, options=options)
png_base64 = base64.b64encode(png_bytes).decode('utf-8')
return jsonify({'preview_html': full_html, 'preview_png_base64': png_base64})
except Exception as e:
traceback.print_exc()
return jsonify({'error': f'Failed to convert content: {str(e)}'}), 500
finally:
if temp_html_path and os.path.exists(temp_html_path):
os.remove(temp_html_path)
# --- MAIN PAGE RENDERER (with corrected JavaScript) ---
@app.route('/')
def index():
highlight_styles = sorted(list(get_all_styles()))
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Intelligent Markdown Converter</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background-color: #f9f9f9; }
h1, h2 { text-align: center; color: #333; }
form { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
textarea { width: 100%; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; padding: 10px; font-family: monospace; }
fieldset { border: 1px solid #ddd; padding: 15px; border-radius: 5px; margin-top: 20px; }
legend { font-weight: bold; color: #555; padding: 0 10px; }
select, input[type="number"], input[type="color"] { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;}
button { padding: 10px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; transition: background-color 0.2s; }
.action-btn { background-color: #007BFF; color: white; font-size: 16px; padding: 12px 20px;}
.generate-btn { background-color: #5a32a3; color: white; font-size: 16px; padding: 12px 20px; }
.download-btn { background-color: #28a745; color: white; display: none; }
.main-actions { display: flex; flex-wrap: wrap; gap: 15px; align-items: center; margin-top: 20px; }
.preview-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #eee; padding-bottom: 10px; margin-bottom: 15px; }
.preview-container { border: 1px solid #ddd; padding: 10px; margin-top: 20px; background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.05); min-height: 100px; }
.preview-container img { max-width: 100%; }
.error { color: #D8000C; background-color: #FFD2D2; padding: 10px; border-radius: 5px; margin-top: 15px; display: none; }
.info { color: #00529B; background-color: #BDE5F8; padding: 10px; border-radius: 5px; margin: 10px 0; display: none;}
.style-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 15px; align-items: end; }
.component-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px; }
.component-container { border: 1px solid #e0e0e0; border-radius: 5px; background: #fafafa; }
.component-header { background: #f1f1f1; padding: 8px 12px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; gap: 10px; }
.component-content textarea { height: 150px; }
.selection-controls { margin: 15px 0; display: flex; gap: 10px; }
</style>
</head>
<body>
<h1>Intelligent Markdown Converter</h1>
<form id="main-form" onsubmit="return false;">
<fieldset><legend>1. Load Content</legend><div id="info-box" class="info"></div><textarea id="markdown-text-input" name="markdown_text" rows="8"></textarea><div style="margin-top: 10px; display: flex; align-items: center; gap: 10px;"><label for="markdown-file-input">Or upload a file:</label><input type="file" id="markdown-file-input" name="markdown_file" accept=".md,.txt,text/markdown"></div><div style="margin-top: 15px;"><button type="button" id="load-btn" class="action-btn">Load & Analyze</button></div></fieldset>
<fieldset id="components-fieldset" style="display:none;"><legend>2. Select Components</legend><div class="selection-controls"><button type="button" onclick="toggleAllComponents(true)">Select All</button><button type="button" onclick="toggleAllComponents(false)">Deselect All</button></div><div id="components-container" class="component-grid"></div></fieldset>
<fieldset><legend>3. Configure Styles</legend><div class="style-grid"><div><label>Font Family:</label><select id="font_family"><optgroup label="Sans-Serif"><option value="'Arial', sans-serif">Arial</option><option value="'Roboto', sans-serif">Roboto</option></optgroup><optgroup label="Serif"><option value="'Times New Roman', serif">Times New Roman</option><option value="'Georgia', serif">Georgia</option></optgroup></select></div><div><label>Font Size (px):</label><input type="number" id="font_size" value="16"></div><div><label>Highlight Theme:</label><select id="highlight_theme"><option value="none">None</option>{% for style in highlight_styles %}<option value="{{ style }}" {% if style == 'default' %}selected{% endif %}>{{ style }}</option>{% endfor %}</select></div><div><label>Text Color:</label><input type="color" id="text_color" value="#333333"></div><div><label>Background Color:</label><input type="color" id="background_color" value="#ffffff"></div><div><label>Code Padding (px):</label><input type="number" id="code_padding" value="15"></div></div><div><input type="checkbox" id="include_fontawesome"><label for="include_fontawesome">Include Font Awesome</label></div><div><label for="custom_css">Custom CSS:</label><textarea id="custom_css" rows="3"></textarea></div></fieldset>
<div class="main-actions"><button type="button" id="generate-btn" class="generate-btn">Generate Preview</button></div>
</form>
<div id="error-box" class="error"></div>
<div id="preview-section" style="display:none;">
<h2>Preview</h2>
<div class="preview-header">
<h3>HTML Output</h3>
<button type="button" id="download-html-btn" class="download-btn">Download HTML</button>
</div>
<div id="html-preview-container" class="preview-container"></div>
<div class="preview-header" style="margin-top: 30px;">
<h3>PNG Output</h3>
<button type="button" id="download-png-btn" class="download-btn">Download PNG</button>
</div>
<div id="png-preview-container" class="preview-container"></div>
</div>
<script>
const loadBtn = document.getElementById('load-btn'), generateBtn = document.getElementById('generate-btn'),
downloadHtmlBtn = document.getElementById('download-html-btn'), downloadPngBtn = document.getElementById('download-png-btn'),
markdownTextInput = document.getElementById('markdown-text-input'), markdownFileInput = document.getElementById('markdown-file-input'),
componentsFieldset = document.getElementById('components-fieldset'), componentsContainer = document.getElementById('components-container'),
previewSection = document.getElementById('preview-section'), htmlPreviewContainer = document.getElementById('html-preview-container'),
pngPreviewContainer = document.getElementById('png-preview-container'), errorBox = document.getElementById('error-box'),
infoBox = document.getElementById('info-box');
function toggleAllComponents(checked) { componentsContainer.querySelectorAll('.component-checkbox').forEach(cb => cb.checked = checked); }
function displayError(message) { errorBox.textContent = message; errorBox.style.display = 'block'; previewSection.style.display = 'none'; }
function buildPayload() {
let finalMarkdown = "";
if (componentsFieldset.style.display === 'block') {
const parts = [];
componentsContainer.querySelectorAll('.component-container').forEach(div => {
if (div.querySelector('.component-checkbox').checked) { parts.push(div.dataset.reconstructed); }
});
finalMarkdown = parts.join('\\n\\n---\\n\\n');
} else { finalMarkdown = markdownTextInput.value; }
return {
markdown_text: finalMarkdown,
styles: {
font_family: document.getElementById('font_family').value, font_size: document.getElementById('font_size').value,
text_color: document.getElementById('text_color').value, background_color: document.getElementById('background_color').value,
code_padding: document.getElementById('code_padding').value, highlight_theme: document.getElementById('highlight_theme').value,
custom_css: document.getElementById('custom_css').value
},
include_fontawesome: document.getElementById('include_fontawesome').checked,
};
}
generateBtn.addEventListener('click', async () => {
generateBtn.textContent = 'Generating...'; generateBtn.disabled = true; errorBox.style.display = 'none';
const payload = buildPayload();
payload.download = false;
try {
const response = await fetch('/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Server error ${response.status}`);
previewSection.style.display = 'block';
htmlPreviewContainer.innerHTML = result.preview_html;
pngPreviewContainer.innerHTML = `<img src="data:image/png;base64,${result.preview_png_base64}" alt="PNG Preview">`;
downloadHtmlBtn.style.display = 'inline-block'; downloadPngBtn.style.display = 'inline-block';
} catch (err) { displayError('Error generating preview: ' + err.message); }
finally { generateBtn.textContent = 'Generate Preview'; generateBtn.disabled = false; }
});
async function handleDownload(fileType) {
const button = fileType === 'html' ? downloadHtmlBtn : downloadPngBtn;
button.textContent = 'Preparing...'; const payload = buildPayload();
payload.download = true; payload.download_type = fileType;
try {
const response = await fetch('/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!response.ok) throw new Error(`Download failed: ${response.statusText}`);
const blob = await response.blob(); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = 'output.' + fileType;
document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); a.remove();
} catch (err) { displayError('Error preparing download: ' + err.message); }
finally { button.textContent = `Download ${fileType.toUpperCase()}`; }
}
downloadHtmlBtn.addEventListener('click', () => handleDownload('html'));
downloadPngBtn.addEventListener('click', () => handleDownload('png'));
loadBtn.addEventListener('click', async () => {
loadBtn.textContent = 'Loading...'; loadBtn.disabled = true; errorBox.style.display = 'none'; infoBox.style.display = 'none'; componentsFieldset.style.display = 'none'; componentsContainer.innerHTML = '';
const formData = new FormData();
if (markdownFileInput.files.length > 0) { formData.append('markdown_file', markdownFileInput.files[0]); } else { formData.append('markdown_text', markdownTextInput.value); }
try {
const response = await fetch('/parse', { method: 'POST', body: formData });
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Server error`);
infoBox.innerHTML = `Detected Format: <strong>${result.format}</strong>`; infoBox.style.display = 'block';
if (result.format !== 'Unknown') {
componentsFieldset.style.display = 'block';
result.components.forEach((comp, index) => {
const div = document.createElement('div');
div.className = 'component-container';
// *** THIS IS THE FIX ***
// We create the full markdown for reconstruction here, ensuring headings are preserved.
let full_content_for_reconstruction;
let display_content = comp.content; // Content for the <textarea>
let content_with_fences = comp.content;
if (comp.is_code_block) {
content_with_fences = "```" + (comp.language || '') + "\\n" + comp.content + "\\n```";
}
if (comp.type === 'file') {
// For repo2md, format each file as a Level 3 heading with its content
full_content_for_reconstruction = `### File: ${comp.filename}\\n${content_with_fences}`;
} else if (comp.type === 'section' || comp.type === 'version') {
// For other formats, use a Level 2 heading
full_content_for_reconstruction = `## ${comp.filename}\\n${comp.content}`;
} else {
// For intro text, use the content as-is
full_content_for_reconstruction = comp.content;
}
div.dataset.reconstructed = full_content_for_reconstruction;
div.innerHTML = `<div class="component-header"><input type="checkbox" id="comp-check-${index}" class="component-checkbox" checked><label for="comp-check-${index}">${comp.filename}</label></div><div class="component-content"><textarea readonly>${display_content}</textarea></div>`;
componentsContainer.appendChild(div);
});
}
if(markdownFileInput.files.length > 0) { markdownTextInput.value = await markdownFileInput.files[0].text(); }
} catch (err) { displayError('Error parsing content: ' + err.message); } finally { loadBtn.textContent = 'Load & Analyze'; loadBtn.disabled = false; }
});
</script>
</body>
</html>
""", highlight_styles=highlight_styles)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) |