initial commit

This commit is contained in:
2026-03-01 11:35:14 -06:00
commit a1428df440
10 changed files with 1557 additions and 0 deletions

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.git
.gitignore
.env
.venv
venv/
ENV/
.DS_Store
*.md
!requirements.txt
.dockerignore
Dockerfile
docker-compose.yml
markdown-app.service
nginx-markdown.conf

227
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,227 @@
# Deployment Guide
This guide covers deploying the Markdown Renderer application to a server with nginx using Docker.
## Prerequisites
- Docker and Docker Compose installed on your server
- nginx installed and running
- sudo/root access to the server
## Deployment Steps
### 1. Transfer Files to Server
Copy the application files to your server:
```bash
# On your local machine
scp -r /Users/brent/markdown user@yourserver:/tmp/markdown-app
# On the server
sudo mkdir -p /opt/markdown-app
sudo mv /tmp/markdown-app/* /opt/markdown-app/
sudo chown -R $USER:$USER /opt/markdown-app
```
### 2. Build and Start the Docker Container
```bash
cd /opt/markdown-app
docker-compose up -d
```
Verify the container is running:
```bash
docker-compose ps
docker-compose logs
```
### 3. Configure nginx
Copy the nginx configuration and enable it:
```bash
# Copy the configuration file
sudo cp /opt/markdown-app/nginx-markdown.conf /etc/nginx/sites-available/markdown
# Edit the configuration to set your domain name
sudo nano /etc/nginx/sites-available/markdown
# Change 'markdown.yourdomain.com' to your actual domain or server IP
# Enable the site
sudo ln -s /etc/nginx/sites-available/markdown /etc/nginx/sites-enabled/
# Test nginx configuration
sudo nginx -t
# Reload nginx
sudo systemctl reload nginx
```
### 4. Enable Auto-Start on Server Boot
Install the systemd service:
```bash
# Copy the service file
sudo cp /opt/markdown-app/markdown-app.service /etc/systemd/system/
# Reload systemd
sudo systemctl daemon-reload
# Enable the service to start on boot
sudo systemctl enable markdown-app.service
# Start the service
sudo systemctl start markdown-app.service
# Check status
sudo systemctl status markdown-app.service
```
### 5. Optional: Configure SSL with Let's Encrypt
If you want HTTPS (recommended for production):
```bash
# Install certbot
sudo apt-get update
sudo apt-get install certbot python3-certbot-nginx
# Obtain certificate (replace with your domain)
sudo certbot --nginx -d markdown.yourdomain.com
# Certbot will automatically configure nginx for HTTPS
```
## Management Commands
### View Logs
```bash
# Docker container logs
docker-compose logs -f
# nginx logs
sudo tail -f /var/log/nginx/markdown-access.log
sudo tail -f /var/log/nginx/markdown-error.log
# Systemd service logs
sudo journalctl -u markdown-app.service -f
```
### Restart Services
```bash
# Restart the Docker container
sudo systemctl restart markdown-app.service
# OR
cd /opt/markdown-app && docker-compose restart
# Restart nginx
sudo systemctl restart nginx
```
### Update the Application
```bash
cd /opt/markdown-app
# Pull new changes (if using git)
git pull
# Rebuild and restart
docker-compose down
docker-compose build --no-cache
docker-compose up -d
# OR if using systemd service
sudo systemctl restart markdown-app.service
```
### Stop Services
```bash
# Stop the Docker container
sudo systemctl stop markdown-app.service
# OR
cd /opt/markdown-app && docker-compose down
```
## Firewall Configuration
If you have a firewall enabled, allow nginx:
```bash
# For ufw
sudo ufw allow 'Nginx Full'
# For firewalld
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
```
## Troubleshooting
### Container won't start
```bash
# Check Docker logs
docker-compose logs
# Check if port 8080 is already in use
sudo lsof -i :8080
```
### nginx returns 502 Bad Gateway
```bash
# Verify container is running
docker-compose ps
# Check if the app is responding
curl http://127.0.0.1:8080
```
### Service doesn't start on boot
```bash
# Check service status
sudo systemctl status markdown-app.service
# Check if service is enabled
sudo systemctl is-enabled markdown-app.service
# View service logs
sudo journalctl -u markdown-app.service --no-pager
```
## Accessing the Application
- If configured with a domain: `http://markdown.yourdomain.com`
- If using server IP: `http://your.server.ip.address`
- With HTTPS: `https://markdown.yourdomain.com`
## Security Recommendations
1. Configure a firewall to only allow ports 80 (HTTP) and 443 (HTTPS)
2. Use HTTPS with Let's Encrypt certificates
3. Keep Docker and nginx updated
4. Consider adding rate limiting in nginx
5. Regularly update the application dependencies
6. Monitor logs for suspicious activity
## Performance Tuning
The docker-compose.yml is configured with:
- 4 gunicorn workers (adjust based on CPU cores)
- 120-second timeout for PDF generation
- Log rotation (max 3 files of 10MB each)
To adjust workers, edit the Dockerfile CMD line:
```dockerfile
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"]
```

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM python:3.11-slim
# Install system dependencies required for WeasyPrint
RUN apt-get update && apt-get install -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libgdk-pixbuf2.0-0 \
libffi-dev \
libcairo2 \
libgirepository-1.0-1 \
gir1.2-pango-1.0 \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# Copy application files
COPY app.py .
COPY templates/ templates/
# Create non-root user for security
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/').read()" || exit 1
# Run with gunicorn for production
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"]

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# Markdown Editor with LaTeX Support
A real-time markdown editor with LaTeX equation rendering in a split-pane web interface.
## Features
- **Split-pane interface**: Left pane for editing, right pane for live preview
- **Real-time rendering**: See your changes as you type (with 300ms debounce)
- **LaTeX support**: Render mathematical equations using MathJax (SVG mode)
- Inline equations: `$E = mc^2$` or `\(E = mc^2\)`
- Block equations: `$$...$$` or `\[...\]`
- **PDF Export**: Export your rendered markdown with properly formatted equations to PDF
- **Markdown features**:
- Headers, bold, italic, lists
- Code blocks with syntax highlighting
- Tables, blockquotes, links, images
- And more...
## Installation
1. Install Python dependencies:
```bash
pip install -r requirements.txt
```
## Usage
1. Start the Flask server:
```bash
python app.py
```
2. Open your browser and navigate to:
```
http://localhost:8080
```
3. Start typing or pasting markdown in the left pane. The right pane will update automatically with the rendered output.
## LaTeX Examples
Inline equation:
```
The famous equation $E = mc^2$ shows the relationship between energy and mass.
```
Block equation:
```
$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$
```
More complex equation:
```
$$
\frac{d}{dx}\left( \int_{0}^{x} f(u)\,du\right)=f(x)
$$
```
## PDF Export
Click the green "Export to PDF" button in the upper right corner of the preview pane to generate a PDF of your rendered markdown. The PDF will include:
- All markdown formatting (headers, lists, tables, code blocks, etc.)
- LaTeX equations rendered as SVG (both inline and display equations)
- Syntax-highlighted code blocks
- Professional typography and layout
## Technology Stack
- **Backend**: Flask (Python)
- **Frontend**: Vanilla JavaScript
- **Markdown**: Python-Markdown library
- **LaTeX**: MathJax 3 (SVG output mode)
- **Syntax Highlighting**: Highlight.js
- **PDF Generation**: WeasyPrint

339
app.py Normal file
View File

@@ -0,0 +1,339 @@
from flask import Flask, render_template, request, jsonify, send_file
import markdown
from markdown.extensions import fenced_code, tables, codehilite
import re
from weasyprint import HTML, CSS
from io import BytesIO
app = Flask(__name__)
# Configure markdown with extensions
md = markdown.Markdown(extensions=[
'fenced_code',
'tables',
'codehilite',
'extra',
'nl2br'
])
def protect_math(text):
"""Protect LaTeX math expressions from markdown processing"""
# Store math expressions
math_blocks = []
# Protect display math ($$...$$)
def save_display_math(match):
math_blocks.append(('display', match.group(1)))
return f'MATHBLOCK{len(math_blocks)-1}MATHBLOCK'
# Protect inline math ($...$)
def save_inline_math(match):
math_blocks.append(('inline', match.group(1)))
return f'MATHBLOCK{len(math_blocks)-1}MATHBLOCK'
# Replace display math first (greedy, multiline)
text = re.sub(r'\$\$(.*?)\$\$', save_display_math, text, flags=re.DOTALL)
# Then inline math (don't allow $ inside, don't cross newlines)
text = re.sub(r'\$([^\$\n]+)\$', save_inline_math, text)
return text, math_blocks
def restore_math(text, math_blocks):
"""Restore LaTeX math expressions after markdown processing"""
for i, (math_type, content) in enumerate(math_blocks):
placeholder = f'MATHBLOCK{i}MATHBLOCK'
if math_type == 'display':
replacement = f'$$\n{content}\n$$'
else:
# Use \( \) delimiters for inline math as they're more robust
replacement = f'\\({content}\\)'
# Replace both the placeholder and any HTML-escaped version
text = text.replace(placeholder, replacement)
return text
@app.route('/')
def index():
return render_template('index.html')
@app.route('/render', methods=['POST'])
def render_markdown():
"""Convert markdown to HTML"""
data = request.get_json()
markdown_text = data.get('markdown', '')
# Protect math expressions before markdown processing
protected_text, math_blocks = protect_math(markdown_text)
# Reset the markdown instance to clear state
md.reset()
# Convert markdown to HTML
html = md.convert(protected_text)
# Restore math expressions after markdown processing
html = restore_math(html, math_blocks)
return jsonify({'html': html})
def convert_latex_to_mathml(html_content):
"""Convert LaTeX expressions to MathML for PDF rendering"""
from latex2mathml.converter import convert as latex_to_mathml
# Convert display math $$...$$ to MathML
def replace_display(match):
latex = match.group(1).strip()
try:
mathml = latex_to_mathml(latex)
return f'<div style="text-align: center; margin: 1em 0;">{mathml}</div>'
except Exception as e:
print(f"Error converting display LaTeX: {latex[:50]}... Error: {e}")
return f'<div style="text-align: center; margin: 1em 0; font-family: monospace;">{latex}</div>'
# Convert inline math \(...\) to MathML
def replace_inline(match):
latex = match.group(1).strip()
try:
mathml = latex_to_mathml(latex)
return mathml
except Exception as e:
print(f"Error converting inline LaTeX: {latex[:50]}... Error: {e}")
return f'<span style="font-family: monospace;">{latex}</span>'
# Process display math
html_content = re.sub(r'\$\$(.*?)\$\$', replace_display, html_content, flags=re.DOTALL)
# Process inline math
html_content = re.sub(r'\\[(](.*?)\\[)]', replace_inline, html_content, flags=re.DOTALL)
return html_content
@app.route('/export-pdf', methods=['POST'])
def export_pdf():
"""Generate PDF from rendered HTML with embedded equation images"""
data = request.get_json()
html_content = data.get('html', '')
# Create a complete HTML document with styles
full_html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {{
size: A4;
margin: 2cm;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.8;
color: #333;
font-size: 11pt;
}}
h1 {{
font-size: 2em;
margin: 0.67em 0;
border-bottom: 2px solid #eee;
padding-bottom: 0.3em;
}}
h2 {{
font-size: 1.5em;
margin: 0.75em 0;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}}
h3 {{
font-size: 1.25em;
margin: 0.83em 0;
}}
p {{
margin: 1em 0;
}}
code {{
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}}
pre {{
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}}
pre code {{
background-color: transparent;
padding: 0;
}}
blockquote {{
border-left: 4px solid #ddd;
padding-left: 16px;
margin: 1em 0;
color: #666;
}}
ul, ol {{
margin: 1em 0;
padding-left: 2em;
}}
li {{
margin: 0.5em 0;
}}
table {{
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}}
th, td {{
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}}
th {{
background-color: #f5f5f5;
font-weight: bold;
}}
tr:nth-child(even) {{
background-color: #f9f9f9;
}}
a {{
color: #0366d6;
text-decoration: none;
}}
img {{
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: middle;
}}
img[style*="display: block"] {{
display: block !important;
margin-left: auto;
margin-right: auto;
}}
hr {{
border: none;
border-top: 2px solid #eee;
margin: 2em 0;
}}
</style>
</head>
<body>
{html_content}
</body>
</html>
'''
# Generate PDF
pdf_file = BytesIO()
HTML(string=full_html).write_pdf(pdf_file)
pdf_file.seek(0)
return send_file(
pdf_file,
mimetype='application/pdf',
as_attachment=True,
download_name='markdown-export.pdf'
)
@app.route('/export-latex', methods=['POST'])
def export_latex():
"""Convert markdown to LaTeX document"""
data = request.get_json()
markdown_text = data.get('markdown', '')
# Convert markdown elements to LaTeX
latex_content = markdown_to_latex(markdown_text)
# Create response with LaTeX file
return jsonify({'latex': latex_content})
def markdown_to_latex(markdown_text):
"""Convert markdown syntax to LaTeX"""
# Start with document structure
latex = r'''\documentclass{article}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{graphicx}
\usepackage{hyperref}
\usepackage{listings}
\usepackage{xcolor}
\lstset{
basicstyle=\ttfamily,
breaklines=true,
frame=single,
backgroundcolor=\color{gray!10}
}
\begin{document}
'''
lines = markdown_text.split('\n')
i = 0
in_code_block = False
code_lang = ''
while i < len(lines):
line = lines[i]
# Code blocks
if line.startswith('```'):
if not in_code_block:
in_code_block = True
code_lang = line[3:].strip()
latex += r'\begin{lstlisting}'
if code_lang:
latex += f'[language={code_lang}]'
latex += '\n'
else:
in_code_block = False
latex += r'\end{lstlisting}' + '\n'
i += 1
continue
if in_code_block:
latex += line + '\n'
i += 1
continue
# Headers
if line.startswith('# '):
latex += r'\section{' + line[2:] + '}\n'
elif line.startswith('## '):
latex += r'\subsection{' + line[3:] + '}\n'
elif line.startswith('### '):
latex += r'\subsubsection{' + line[4:] + '}\n'
# Horizontal rule
elif line.strip() == '---' or line.strip() == '***':
latex += r'\hrulefill' + '\n\n'
# Empty lines
elif line.strip() == '':
latex += '\n'
# Regular paragraphs
else:
# Convert inline markdown
processed_line = line
# Bold
processed_line = re.sub(r'\*\*(.+?)\*\*', r'\\textbf{\1}', processed_line)
# Italic
processed_line = re.sub(r'\*(.+?)\*', r'\\textit{\1}', processed_line)
# Inline code
processed_line = re.sub(r'`(.+?)`', r'\\texttt{\1}', processed_line)
latex += processed_line + '\n'
i += 1
latex += r'''
\end{document}
'''
return latex
if __name__ == '__main__':
app.run(debug=True, port=8080)

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.8'
services:
markdown-app:
build: .
container_name: markdown-renderer
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
- FLASK_ENV=production
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/').read()"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

17
markdown-app.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Markdown Renderer Docker Container
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/markdown-app
ExecStart=/usr/bin/docker-compose up -d
ExecStop=/usr/bin/docker-compose down
ExecReload=/usr/bin/docker-compose restart
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target

52
nginx-markdown.conf Normal file
View File

@@ -0,0 +1,52 @@
server {
listen 80;
server_name markdown.yourdomain.com; # Change this to your domain
# Optional: Redirect HTTP to HTTPS
# return 301 https://$server_name$request_uri;
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for PDF generation
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# Optional: Access and error logs
access_log /var/log/nginx/markdown-access.log;
error_log /var/log/nginx/markdown-error.log;
}
# Optional: HTTPS configuration (uncomment and configure after obtaining SSL certificate)
# server {
# listen 443 ssl http2;
# server_name markdown.yourdomain.com;
#
# ssl_certificate /etc/letsencrypt/live/markdown.yourdomain.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/markdown.yourdomain.com/privkey.pem;
#
# client_max_body_size 10M;
#
# location / {
# proxy_pass http://127.0.0.1:8080;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
#
# proxy_connect_timeout 120s;
# proxy_send_timeout 120s;
# proxy_read_timeout 120s;
# }
#
# access_log /var/log/nginx/markdown-access.log;
# error_log /var/log/nginx/markdown-error.log;
# }

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask==3.0.0
markdown==3.5.1
Pygments==2.17.2
weasyprint==62.3
pydyf==0.11.0
latex2mathml==3.77.0

754
templates/index.html Normal file
View File

@@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Editor with LaTeX</title>
<!-- MathJax for LaTeX rendering -->
<script>
MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']]
},
svg: {
fontCache: 'global'
}
};
</script>
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-svg.js"></script>
<!-- Highlight.js for code syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
}
.pane {
flex: 1;
overflow: auto;
padding: 20px;
}
.editor-pane {
background-color: #f5f5f5;
border-right: 2px solid #ddd;
}
.preview-pane {
background-color: #ffffff;
padding: 40px;
position: relative;
}
.export-buttons {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 10px;
z-index: 1000;
flex-wrap: wrap;
}
.export-button {
padding: 10px 20px;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: background-color 0.3s;
}
.pdf-button {
background-color: #4CAF50;
}
.pdf-button:hover {
background-color: #45a049;
}
.pdf-button:active {
background-color: #3d8b40;
}
.latex-button {
background-color: #2196F3;
}
.latex-button:hover {
background-color: #0b7dda;
}
.latex-button:active {
background-color: #0968b8;
}
.clipboard-button {
background-color: #FF9800;
}
.clipboard-button:hover {
background-color: #F57C00;
}
.clipboard-button:active {
background-color: #E65100;
}
.export-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
#editor {
width: 100%;
height: calc(100vh - 40px);
padding: 15px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
resize: none;
background-color: #ffffff;
line-height: 1.6;
}
#editor:focus {
outline: none;
border-color: #4CAF50;
}
#preview {
line-height: 1.8;
color: #333;
}
/* Markdown styling */
#preview h1 {
font-size: 2em;
margin: 0.67em 0;
border-bottom: 2px solid #eee;
padding-bottom: 0.3em;
}
#preview h2 {
font-size: 1.5em;
margin: 0.75em 0;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
#preview h3 {
font-size: 1.25em;
margin: 0.83em 0;
}
#preview h4, #preview h5, #preview h6 {
margin: 1em 0;
}
#preview p {
margin: 1em 0;
}
#preview code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}
#preview pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
#preview pre code {
background-color: transparent;
padding: 0;
}
#preview blockquote {
border-left: 4px solid #ddd;
padding-left: 16px;
margin: 1em 0;
color: #666;
}
#preview ul, #preview ol {
margin: 1em 0;
padding-left: 2em;
}
#preview li {
margin: 0.5em 0;
}
#preview table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
#preview th, #preview td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
#preview th {
background-color: #f5f5f5;
font-weight: bold;
}
#preview tr:nth-child(even) {
background-color: #f9f9f9;
}
#preview a {
color: #0366d6;
text-decoration: none;
}
#preview a:hover {
text-decoration: underline;
}
#preview img {
max-width: 100%;
height: auto;
}
#preview hr {
border: none;
border-top: 2px solid #eee;
margin: 2em 0;
}
/* LaTeX equation styling */
.MathJax {
font-size: 1.1em;
}
</style>
</head>
<body>
<div class="container">
<div class="pane editor-pane">
<textarea id="editor" placeholder="Type or paste your markdown here...
Examples:
# Heading 1
## Heading 2
**Bold text** and *italic text*
Inline equation: $E = mc^2$
Block equation:
$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$
- List item 1
- List item 2
```python
def hello():
print('Hello, World!')
```
"></textarea>
</div>
<div class="pane preview-pane">
<div class="export-buttons">
<button id="clipboardButton" class="export-button clipboard-button">Copy to Clipboard</button>
<button id="latexButton" class="export-button latex-button">Export to LaTeX</button>
<button id="pdfButton" class="export-button pdf-button">Export to PDF</button>
</div>
<div id="preview"></div>
</div>
</div>
<script>
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
let debounceTimer;
// Function to render markdown
async function renderMarkdown() {
const markdownText = editor.value;
try {
const response = await fetch('/render', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ markdown: markdownText })
});
const data = await response.json();
preview.innerHTML = data.html;
// Apply syntax highlighting to code blocks
preview.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Trigger MathJax to render LaTeX
if (window.MathJax) {
MathJax.typesetPromise([preview]).catch((err) => console.log(err));
}
} catch (error) {
console.error('Error rendering markdown:', error);
}
}
// Debounce input to avoid too many requests
editor.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderMarkdown, 300);
});
// Handle Tab key in editor
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
// Get cursor position
const start = editor.selectionStart;
const end = editor.selectionEnd;
// Insert tab character
const value = editor.value;
editor.value = value.substring(0, start) + '\t' + value.substring(end);
// Move cursor after the tab
editor.selectionStart = editor.selectionEnd = start + 1;
// Trigger re-render
editor.dispatchEvent(new Event('input'));
}
});
// Helper function to convert SVG to PNG data URL
async function svgToPng(svgElement, width, height) {
return new Promise((resolve, reject) => {
// Create a canvas
const canvas = document.createElement('canvas');
const scale = 2; // 2x for better quality
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext('2d');
// Scale for better quality
ctx.scale(scale, scale);
// White background
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, width, height);
// Serialize SVG to string
const svgString = new XMLSerializer().serializeToString(svgElement);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
// Load SVG into an image
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load SVG'));
};
img.src = url;
});
}
// Copy to Clipboard function
const clipboardButton = document.getElementById('clipboardButton');
clipboardButton.addEventListener('click', async () => {
const button = clipboardButton;
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Copying...';
try {
// Wait for MathJax to finish rendering
if (window.MathJax) {
await MathJax.typesetPromise([preview]);
}
// Clone the preview to avoid modifying the displayed content
const previewClone = preview.cloneNode(true);
// Find the MathJax defs (font path definitions)
let mjxDefs = document.getElementById('MJX-SVG-global-cache');
if (!mjxDefs) {
const allDefs = document.querySelectorAll('svg defs');
if (allDefs.length > 0) {
mjxDefs = allDefs[allDefs.length - 1];
}
}
// Convert MathJax equations to PNG images
const mjxContainers = previewClone.querySelectorAll('mjx-container');
const originalContainers = preview.querySelectorAll('mjx-container');
for (let i = 0; i < mjxContainers.length; i++) {
const container = mjxContainers[i];
const originalContainer = originalContainers[i];
const svg = container.querySelector('svg');
const originalSvg = originalContainer.querySelector('svg');
if (svg && originalSvg) {
// Get dimensions from the original (rendered) SVG
const bbox = originalSvg.getBoundingClientRect();
const width = bbox.width;
const height = bbox.height;
// Set explicit dimensions on the SVG
svg.setAttribute('width', width + 'px');
svg.setAttribute('height', height + 'px');
// Ensure xmlns
if (!svg.hasAttribute('xmlns')) {
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
// Add MathJax font definitions if found and not already present
if (mjxDefs && !svg.querySelector('defs')) {
const defsClone = mjxDefs.cloneNode(true);
svg.insertBefore(defsClone, svg.firstChild);
}
// Convert SVG to PNG
const pngDataUrl = await svgToPng(svg, width, height);
// Create an img element
const img = document.createElement('img');
img.src = pngDataUrl;
img.style.verticalAlign = 'middle';
// Scale to 70%
img.width = width * 0.7;
img.height = height * 0.7;
// Check if it's a display equation
const isDisplay = container.hasAttribute('display') ||
window.getComputedStyle(originalContainer).display === 'block';
if (isDisplay) {
img.style.display = 'block';
img.style.margin = '1em auto';
} else {
img.style.display = 'inline-block';
}
// Replace the container with the image
container.replaceWith(img);
}
}
// Apply inline styles to tables for better Word compatibility
const tables = previewClone.querySelectorAll('table');
tables.forEach(table => {
table.style.borderCollapse = 'collapse';
table.style.width = '100%';
table.style.margin = '1em 0';
const cells = table.querySelectorAll('th, td');
cells.forEach(cell => {
cell.style.border = '1px solid #ddd';
cell.style.padding = '8px 12px';
cell.style.textAlign = 'left';
});
const headers = table.querySelectorAll('th');
headers.forEach(th => {
th.style.backgroundColor = '#f5f5f5';
th.style.fontWeight = 'bold';
});
const rows = table.querySelectorAll('tr');
rows.forEach((row, index) => {
if (index % 2 === 1) { // Even rows (0-indexed, so odd index)
row.style.backgroundColor = '#f9f9f9';
}
});
});
// Get the HTML content with styling
const styledHtml = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.8; color: #333;">
${previewClone.innerHTML}
</div>
`;
// Create a blob with HTML content
const blob = new Blob([styledHtml], { type: 'text/html' });
// Use the Clipboard API to copy both HTML and plain text
await navigator.clipboard.write([
new ClipboardItem({
'text/html': blob,
'text/plain': new Blob([previewClone.innerText], { type: 'text/plain' })
})
]);
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
} catch (error) {
console.error('Error copying to clipboard:', error);
alert('Failed to copy to clipboard. Please try again.');
} finally {
button.disabled = false;
}
});
// LaTeX export function
const latexButton = document.getElementById('latexButton');
latexButton.addEventListener('click', async () => {
const button = latexButton;
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Generating LaTeX...';
try {
const markdownText = editor.value;
// Send to backend for LaTeX generation
const response = await fetch('/export-latex', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ markdown: markdownText })
});
if (!response.ok) {
throw new Error('LaTeX generation failed');
}
const data = await response.json();
const latexContent = data.latex;
// Download the LaTeX file
const blob = new Blob([latexContent], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.tex';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Error generating LaTeX:', error);
alert('Failed to generate LaTeX. Please try again.');
} finally {
button.disabled = false;
button.textContent = originalText;
}
});
// PDF export function
const pdfButton = document.getElementById('pdfButton');
pdfButton.addEventListener('click', async () => {
const button = pdfButton;
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Generating PDF...';
try {
// Wait for MathJax to finish rendering
if (window.MathJax) {
await MathJax.typesetPromise([preview]);
}
// First, collect dimensions from the original DOM elements (before cloning)
const dimensions = [];
const originalContainers = preview.querySelectorAll('mjx-container');
originalContainers.forEach(container => {
const svg = container.querySelector('svg');
if (svg) {
const bbox = svg.getBoundingClientRect();
dimensions.push({ width: bbox.width, height: bbox.height });
}
});
// Clone the preview to avoid modifying the displayed content
const previewClone = preview.cloneNode(true);
// Find the MathJax defs (font path definitions)
// MathJax SVG mode stores font paths in a global cache SVG element
let mjxDefs = document.getElementById('MJX-SVG-global-cache');
if (!mjxDefs) {
// Fallback: try to find defs in any SVG on the page
const allDefs = document.querySelectorAll('svg defs');
if (allDefs.length > 0) {
mjxDefs = allDefs[allDefs.length - 1]; // Get the last one (likely the font cache)
}
}
// Convert MathJax equations to SVG for PDF with proper dimensions
const mjxContainers = previewClone.querySelectorAll('mjx-container');
mjxContainers.forEach((container, i) => {
const svg = container.querySelector('svg');
if (svg && dimensions[i]) {
const { width, height } = dimensions[i];
// Set explicit dimensions
svg.setAttribute('width', width + 'px');
svg.setAttribute('height', height + 'px');
// Ensure xmlns
if (!svg.hasAttribute('xmlns')) {
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
// Add MathJax font definitions if found and not already present
if (mjxDefs && !svg.querySelector('defs')) {
const defsClone = mjxDefs.cloneNode(true);
svg.insertBefore(defsClone, svg.firstChild);
}
// Create wrapper
const wrapper = document.createElement('div');
wrapper.style.display = 'inline-block';
wrapper.style.verticalAlign = 'middle';
if (container.hasAttribute('display') || window.getComputedStyle(container).display === 'block') {
wrapper.style.display = 'block';
wrapper.style.textAlign = 'center';
wrapper.style.margin = '1em 0';
}
wrapper.appendChild(svg.cloneNode(true));
container.replaceWith(wrapper);
}
});
// Get the HTML with SVG equations
const html = previewClone.innerHTML;
// Send to backend for PDF generation
const response = await fetch('/export-pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ html: html })
});
if (!response.ok) {
throw new Error('PDF generation failed');
}
// Download the PDF
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'markdown-export.pdf';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
button.disabled = false;
button.textContent = originalText;
}
});
// Synchronized scrolling
const editorElement = document.getElementById('editor');
const previewPane = document.querySelector('.preview-pane');
let syncTimeout = null;
let isEditorScrolling = false;
let isPreviewScrolling = false;
// Sync preview scroll with editor scroll
editorElement.addEventListener('scroll', () => {
if (isPreviewScrolling) return;
clearTimeout(syncTimeout);
isEditorScrolling = true;
const sourceScrollHeight = editorElement.scrollHeight - editorElement.clientHeight;
const targetScrollHeight = previewPane.scrollHeight - previewPane.clientHeight;
if (sourceScrollHeight > 0 && targetScrollHeight > 0) {
const scrollPercentage = editorElement.scrollTop / sourceScrollHeight;
previewPane.scrollTop = scrollPercentage * targetScrollHeight;
}
syncTimeout = setTimeout(() => {
isEditorScrolling = false;
}, 50);
});
// Sync editor scroll with preview scroll
previewPane.addEventListener('scroll', () => {
if (isEditorScrolling) return;
clearTimeout(syncTimeout);
isPreviewScrolling = true;
const sourceScrollHeight = previewPane.scrollHeight - previewPane.clientHeight;
const targetScrollHeight = editorElement.scrollHeight - editorElement.clientHeight;
if (sourceScrollHeight > 0 && targetScrollHeight > 0) {
const scrollPercentage = previewPane.scrollTop / sourceScrollHeight;
editorElement.scrollTop = scrollPercentage * targetScrollHeight;
}
syncTimeout = setTimeout(() => {
isPreviewScrolling = false;
}, 50);
});
// Initial render
renderMarkdown();
</script>
</body>
</html>