initial commit
This commit is contained in:
24
.dockerignore
Normal file
24
.dockerignore
Normal 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
227
DEPLOYMENT.md
Normal 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
40
Dockerfile
Normal 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
76
README.md
Normal 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
339
app.py
Normal 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
22
docker-compose.yml
Normal 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
17
markdown-app.service
Normal 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
52
nginx-markdown.conf
Normal 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
6
requirements.txt
Normal 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
754
templates/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user