144bfb2ba7
This commit introduces a complete overhaul of the application, replacing the previous simple Markdown editor with a powerful, Google Docs-style editor with a live preview and ODT export. The new features include: - A new three-pane UI with a Markdown editor, a live preview, and a styles panel. - A powerful styling engine that allows users to create, edit, and apply custom styles to the document. - A persistent storage mechanism for styles using a JSON file. - Support for advanced Markdown features, including code blocks and blockquotes. - A hyperlinked table of contents that is automatically generated from the headings in the document. - Auto-captioning for tables and images. - Pagination for large tables. - Secure image embedding with a domain whitelist and size limits to prevent SSRF and DoS attacks. - An improved live preview that correctly renders multi-line Markdown elements.
325 lines
11 KiB
Python
325 lines
11 KiB
Python
from flask import Flask, render_template, request, send_file, jsonify
|
|
import markdown
|
|
from odfdo import Document, Paragraph, Header, ListItem, List, Table, Row as TableRow, Cell as TableCell, Span, Style, Frame, Image
|
|
import io
|
|
import json
|
|
import re
|
|
import requests
|
|
import os
|
|
import tempfile
|
|
from urllib.parse import urlparse
|
|
|
|
app = Flask(__name__)
|
|
|
|
ALLOWED_IMAGE_DOMAINS = ["upload.wikimedia.org"]
|
|
|
|
# Load styles from file or create default styles
|
|
try:
|
|
with open('styles.json', 'r') as f:
|
|
styles = json.load(f)
|
|
except FileNotFoundError:
|
|
styles = {
|
|
"Normal": {
|
|
"font_size": "12pt",
|
|
"font_family": "Liberation Serif",
|
|
"margin_bottom": "0.1in"
|
|
},
|
|
"Heading 1": {
|
|
"font_size": "18pt",
|
|
"font_family": "Liberation Sans",
|
|
"font_weight": "bold",
|
|
"margin_bottom": "0.2in"
|
|
},
|
|
"Heading 2": {
|
|
"font_size": "14pt",
|
|
"font_family": "Liberation Sans",
|
|
"font_weight": "bold",
|
|
"margin_bottom": "0.15in"
|
|
},
|
|
"Caption": {
|
|
"font_size": "10pt",
|
|
"font_family": "Liberation Serif",
|
|
"font_style": "italic",
|
|
"text_align": "center"
|
|
},
|
|
"Code": {
|
|
"font_family": "Courier New",
|
|
"font_size": "10pt",
|
|
"background_color": "#f0f0f0",
|
|
"padding": "10px"
|
|
},
|
|
"Quote": {
|
|
"font_style": "italic",
|
|
"border_left": "3px solid #ccc",
|
|
"padding_left": "10px"
|
|
}
|
|
}
|
|
with open('styles.json', 'w') as f:
|
|
json.dump(styles, f, indent=4)
|
|
|
|
@app.route('/styles', methods=['GET'])
|
|
def get_styles():
|
|
return jsonify(styles)
|
|
|
|
@app.route('/styles/<style_name>', methods=['GET', 'POST'])
|
|
def manage_style(style_name):
|
|
if request.method == 'POST':
|
|
data = request.json
|
|
styles[style_name] = data
|
|
with open('styles.json', 'w') as f:
|
|
json.dump(styles, f, indent=4)
|
|
return jsonify({"message": f"Style '{style_name}' updated successfully."})
|
|
else:
|
|
return jsonify(styles.get(style_name, {}))
|
|
|
|
def generate_toc(markdown_text):
|
|
toc = []
|
|
for line in markdown_text.splitlines():
|
|
if line.startswith('# '):
|
|
toc.append((1, line[2:].strip()))
|
|
elif line.startswith('## '):
|
|
toc.append((2, line[3:].strip()))
|
|
return toc
|
|
|
|
def apply_styles_to_doc(doc):
|
|
for name, properties in styles.items():
|
|
style = Style(
|
|
'paragraph',
|
|
name=name,
|
|
**{
|
|
"fo:font-size": properties.get("font_size"),
|
|
"fo:font-family": properties.get("font_family"),
|
|
"fo:font-weight": properties.get("font_weight"),
|
|
"fo:font-style": properties.get("font_style"),
|
|
"fo:text-align": properties.get("text_align"),
|
|
"fo:border-left": properties.get("border_left"),
|
|
"fo:padding-left": properties.get("padding_left"),
|
|
"fo:background-color": properties.get("background_color"),
|
|
"fo:padding": properties.get("padding"),
|
|
"fo:margin-bottom": properties.get("margin_bottom")
|
|
}
|
|
)
|
|
doc.insert_style(style, automatic=True)
|
|
|
|
def convert_md_to_odt(markdown_text, applied_styles, max_rows=10):
|
|
doc = Document('text')
|
|
apply_styles_to_doc(doc)
|
|
body = doc.body
|
|
table_counter = 0
|
|
image_counter = 0
|
|
|
|
lines = markdown_text.splitlines()
|
|
i = 0
|
|
toc_placeholder_index = -1
|
|
for idx, line in enumerate(lines):
|
|
if "[TOC]" in line:
|
|
toc_placeholder_index = idx
|
|
break
|
|
|
|
headings = generate_toc(markdown_text)
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
style_name = applied_styles.get(str(i), "Normal")
|
|
|
|
if i == toc_placeholder_index:
|
|
toc_list = List()
|
|
for level, text in headings:
|
|
item = ListItem()
|
|
p = Paragraph()
|
|
bookmark_name = f"__RefHeading_{hash(text)}"
|
|
link = Span(text)
|
|
link.set_attribute('xlink:href', f"#{bookmark_name}")
|
|
link.set_attribute('xlink:type', 'simple')
|
|
p.append(link)
|
|
item.append(p)
|
|
toc_list.append(item)
|
|
body.append(toc_list)
|
|
i += 1
|
|
continue
|
|
|
|
if line.startswith('# '):
|
|
text = line[2:].strip()
|
|
h = Header(1, text=text)
|
|
h.style = style_name
|
|
bookmark_name = f"__RefHeading_{hash(text)}"
|
|
h.set_bookmark(bookmark_name)
|
|
body.append(h)
|
|
elif line.startswith('## '):
|
|
text = line[3:].strip()
|
|
h = Header(2, text=text)
|
|
h.style = style_name
|
|
bookmark_name = f"__RefHeading_{hash(text)}"
|
|
h.set_bookmark(bookmark_name)
|
|
body.append(h)
|
|
elif line.startswith('- '):
|
|
odt_list = List()
|
|
while i < len(lines) and lines[i].strip().startswith('- '):
|
|
item_style = applied_styles.get(str(i), "Normal")
|
|
odt_list.append(ListItem(Paragraph(text=lines[i].strip()[2:], style=item_style)))
|
|
i += 1
|
|
body.append(odt_list)
|
|
i -= 1
|
|
elif line.startswith('|'):
|
|
table_lines = []
|
|
while i < len(lines) and lines[i].strip().startswith('|'):
|
|
table_lines.append(lines[i].strip())
|
|
i += 1
|
|
|
|
if len(table_lines) > 1:
|
|
headers = [h.strip() for h in table_lines[0].strip('|').split('|')]
|
|
table_counter += 1
|
|
|
|
rows = table_lines[2:]
|
|
num_rows = len(rows)
|
|
|
|
for j in range(0, num_rows, max_rows):
|
|
chunk = rows[j:j + max_rows]
|
|
|
|
chunk_table = Table(name=f"Table{table_counter}_{j}", width=len(headers))
|
|
|
|
header_row = TableRow()
|
|
for header in headers:
|
|
cell = TableCell()
|
|
cell.append(Paragraph(text=header))
|
|
header_row.append(cell)
|
|
chunk_table.append(header_row)
|
|
|
|
for row_line in chunk:
|
|
row_cells = [c.strip() for c in row_line.strip('|').split('|')]
|
|
row = TableRow()
|
|
for cell_text in row_cells:
|
|
cell = TableCell()
|
|
cell.append(Paragraph(text=cell_text))
|
|
row.append(cell)
|
|
chunk_table.append(row)
|
|
body.append(chunk_table)
|
|
|
|
caption_text = f"Table {table_counter}"
|
|
if j > 0:
|
|
caption_text += " (continued)"
|
|
caption = Paragraph(caption_text, style="Caption")
|
|
body.append(caption)
|
|
elif line.startswith('!['):
|
|
match = re.match(r'!\[(.*?)\]\((.*?)\)', line)
|
|
if match:
|
|
alt_text = match.group(1)
|
|
image_url = match.group(2)
|
|
|
|
try:
|
|
domain = urlparse(image_url).netloc
|
|
if domain not in ALLOWED_IMAGE_DOMAINS:
|
|
raise ValueError("Image domain not allowed.")
|
|
|
|
response = requests.get(image_url, stream=True, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
# Check for content length
|
|
content_length = response.headers.get('content-length')
|
|
if content_length and int(content_length) > 10 * 1024 * 1024:
|
|
raise ValueError("Image file is too large.")
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
|
|
for chunk in response.iter_content(chunk_size=8192):
|
|
temp_file.write(chunk)
|
|
|
|
# Add the image to the document
|
|
image_data = doc.add_file(temp_file.name)
|
|
|
|
# Create a frame and insert the image
|
|
frame = Frame(width="10cm", height="10cm")
|
|
frame.append(Image(url=image_data))
|
|
body.append(frame)
|
|
|
|
# Add the caption
|
|
image_counter += 1
|
|
caption = Paragraph(f"Figure {image_counter}: {alt_text}", style="Caption")
|
|
body.append(caption)
|
|
|
|
# Clean up the temporary file
|
|
os.unlink(temp_file.name)
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
p = Paragraph(f"[Image not found: {alt_text}]", style="Normal")
|
|
body.append(p)
|
|
elif line.startswith('```'):
|
|
code_lines = []
|
|
i += 1
|
|
while i < len(lines) and not lines[i].startswith('```'):
|
|
code_lines.append(lines[i])
|
|
i += 1
|
|
code_block = '\n'.join(code_lines)
|
|
p = Paragraph(code_block, style="Code")
|
|
body.append(p)
|
|
elif line.startswith('> '):
|
|
p = Paragraph(line[2:], style="Quote")
|
|
body.append(p)
|
|
elif line:
|
|
p = Paragraph(style=style_name)
|
|
parts = re.split(r'(\\*\\*.*\\*\\*|\\*.*\\*)', line)
|
|
for part in parts:
|
|
if part.startswith('**') and part.endswith('**'):
|
|
p.append(Span(part[2:-2], style='bold'))
|
|
elif part.startswith('*') and part.endswith('*'):
|
|
p.append(Span(part[1:-1], style='italic'))
|
|
else:
|
|
p.append(part)
|
|
body.append(p)
|
|
|
|
i += 1
|
|
|
|
return doc
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return render_template('index.html')
|
|
|
|
@app.route('/preview', methods=['POST'])
|
|
def preview():
|
|
data = request.json
|
|
markdown_text = data.get('markdown', '')
|
|
applied_styles = data.get('styles', {})
|
|
|
|
# Generate CSS from styles
|
|
css = ""
|
|
for name, properties in styles.items():
|
|
css += f".style-{name.replace(' ', '-')} {{"
|
|
for prop, value in properties.items():
|
|
css += f"{prop.replace('_', '-')}: {value};"
|
|
css += "}"
|
|
|
|
# Generate styled HTML
|
|
html = markdown.markdown(markdown_text, extensions=['tables'])
|
|
lines = html.splitlines()
|
|
styled_html = ""
|
|
for i, line in enumerate(lines):
|
|
style_name = applied_styles.get(str(i), "Normal").replace(' ', '-')
|
|
styled_html += f"<div class='style-{style_name}'>{line}</div>"
|
|
|
|
full_html = f"<style>{css}</style>{styled_html}"
|
|
|
|
return jsonify({'html': full_html})
|
|
|
|
@app.route('/export', methods=['POST'])
|
|
def export():
|
|
data = request.json
|
|
markdown_text = data.get('markdown', '')
|
|
applied_styles = data.get('styles', {})
|
|
|
|
doc = convert_md_to_odt(markdown_text, applied_styles)
|
|
|
|
file_stream = io.BytesIO()
|
|
doc.save(file_stream, pretty=True)
|
|
file_stream.seek(0)
|
|
|
|
return send_file(
|
|
file_stream,
|
|
as_attachment=True,
|
|
download_name='document.odt',
|
|
mimetype='application/vnd.oasis.opendocument.text'
|
|
)
|
|
|
|
if __name__ == '__main__':
|
|
app.run(debug=True, host='0.0.0.0', port=5000)
|