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