from flask import Flask, render_template, request, send_file, jsonify import markdown from docx import Document from docx.shared import Inches, Pt from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.style import WD_STYLE_TYPE from docx.oxml.ns import qn import io import json import re from werkzeug.utils import secure_filename app = Flask(__name__) # Global variables for tracking captions and table pagination image_counter = 0 table_counter = 0 def add_custom_styles(doc): """Add custom styles to the document""" # Heading 1 style if "Heading 1" not in doc.styles: h1 = doc.styles.add_style('Heading 1', WD_STYLE_TYPE.PARAGRAPH) h1.font.name = 'Arial' h1.font.size = Pt(16) h1.font.bold = True h1.paragraph_format.space_after = Pt(12) # Heading 2 style if "Heading 2" not in doc.styles: h2 = doc.styles.add_style('Heading 2', WD_STYLE_TYPE.PARAGRAPH) h2.font.name = 'Arial' h2.font.size = Pt(14) h2.font.bold = True h2.paragraph_format.space_after = Pt(10) # Heading 3 style if "Heading 3" not in doc.styles: h3 = doc.styles.add_style('Heading 3', WD_STYLE_TYPE.PARAGRAPH) h3.font.name = 'Arial' h3.font.size = Pt(12) h3.font.bold = True h3.paragraph_format.space_after = Pt(8) # Quote style if "Quote" not in doc.styles: quote = doc.styles.add_style('Quote', WD_STYLE_TYPE.PARAGRAPH) quote.font.name = 'Arial' quote.font.size = Pt(11) quote.font.italic = True quote.paragraph_format.left_indent = Inches(0.5) quote.paragraph_format.right_indent = Inches(0.5) quote.paragraph_format.line_spacing = 1.1 def convert_md_to_docx(markdown_text, config): """Convert Markdown text to Word document with custom settings""" global image_counter, table_counter image_counter = 0 table_counter = 0 # Create a new document doc = Document() # Add custom styles add_custom_styles(doc) # Parse markdown into lines lines = markdown_text.split('\n') i = 0 while i < len(lines): line = lines[i].strip() if line.startswith('# '): # Heading 1 doc.add_heading(line[2:], level=0) elif line.startswith('## '): # Heading 2 doc.add_heading(line[3:], level=1) elif line.startswith('### '): # Heading 3 doc.add_heading(line[4:], level=2) elif line.startswith('> '): # Blockquote p = doc.add_paragraph(line[2:], style='Quote') elif line.startswith('|') and i + 1 < len(lines) and lines[i + 1].strip().startswith('|'): # Table processing table_lines = [] while i < len(lines) and lines[i].strip().startswith('|'): table_lines.append(lines[i].strip()) i += 1 # Process the table if table_lines: headers = [cell.strip() for cell in table_lines[0].strip('|').split('|')] separators = [cell.strip() for cell in table_lines[1].strip('|').split('|')] rows = [] for j in range(2, len(table_lines)): row = [cell.strip() for cell in table_lines[j].strip('|').split('|')] rows.append(row) # Create table table = doc.add_table(rows=1, cols=len(headers)) table.style = 'Table Grid' # Add headers hdr_cells = table.rows[0].cells for j, header in enumerate(headers): if j < len(hdr_cells): hdr_cells[j].text = header # Add data rows for row_data in rows: row_cells = table.add_row().cells for j, cell_data in enumerate(row_data): if j < len(row_cells): row_cells[j].text = cell_data # Add table caption if enabled if config.get('autoCaptionTables', True): table_counter += 1 caption_para = doc.add_paragraph() caption_run = caption_para.add_run(f'Table {table_counter}: ') caption_run.italic = True caption_run.font.size = Pt(10) caption_run.font.name = 'Arial' caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER elif line.startswith('```'): # Code block code_lines = [] lang = line[3:] if len(line) > 3 else '' 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 = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.LEFT run = p.add_run(code_block) run.font.name = 'Courier New' run.font.size = Pt(10) # Apply shading to the paragraph - simplified approach # For now, just add the code block as formatted text pass elif line.startswith('- ') or line.startswith('* '): # List item p = doc.add_paragraph(line[2:], style='List Bullet') elif line.startswith('!['): # Image with potential caption # Extract image info: ![alt text](url) match = re.match(r'!\[(.*?)\]\((.*?)\)', line) if match: alt_text = match.group(1) img_url = match.group(2) # Add image if it's a valid local file try: # For this example, we'll just add a placeholder since we don't have actual image files p = doc.add_paragraph('Image Placeholder: ' + alt_text) p.alignment = WD_ALIGN_PARAGRAPH.CENTER # Add caption if enabled if config.get('autoCaptionImages', True): image_counter += 1 caption_para = doc.add_paragraph() caption_run = caption_para.add_run(f'Figure {image_counter}: {alt_text}') caption_run.italic = True caption_run.font.size = Pt(10) caption_run.font.name = 'Arial' caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER except: # If image can't be added, just add alt text doc.add_paragraph(alt_text) elif line == '': # Empty line - add spacing doc.add_paragraph('') else: # Regular paragraph if line: doc.add_paragraph(line) i += 1 return doc @app.route('/') def index(): return render_template('index.html') @app.route('/export', methods=['POST']) def export(): markdown_text = request.form.get('markdown', '') config_json = request.form.get('config', '{}') try: config = json.loads(config_json) except: config = {} # Convert markdown to docx doc = convert_md_to_docx(markdown_text, config) # Save to BytesIO object file_stream = io.BytesIO() doc.save(file_stream) file_stream.seek(0) # Return the file for download return send_file( file_stream, as_attachment=True, download_name='document.docx', mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document' ) if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=5000)