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/', 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"
{line}
" full_html = f"{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)