Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 144bfb2ba7 | |||
| 368426f0f4 |
+48
-3
@@ -1,3 +1,48 @@
|
||||
.idea
|
||||
.venv
|
||||
*.docx
|
||||
```
|
||||
# Compiled and build artifacts
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.o
|
||||
*.obj
|
||||
|
||||
# Dependencies
|
||||
venv/
|
||||
.venv/
|
||||
node_modules/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# Logs and temp files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
coverage/
|
||||
htmlcov/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# GhostEditor - Markdown to Word Converter
|
||||
|
||||
A web-based application that converts Markdown to Word documents with advanced features including real-time preview, styling options, auto-captioning, and table pagination.
|
||||
|
||||
## Features
|
||||
|
||||
1. **Real-time Conversion**: See your Markdown text convert to Word format in real-time
|
||||
2. **Styling Options**: Apply different styles to text elements (headings, quotes, code blocks, etc.)
|
||||
3. **Auto Captioning**: Automatic captions for images, tables, and figures
|
||||
4. **Table Pagination**: Support for tables that span across pages with repeating headers
|
||||
5. **Custom Settings**: Configure document settings and styling preferences
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.7+
|
||||
- Flask
|
||||
- python-docx
|
||||
- markdown
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the required packages:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Run the application:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
3. Open your browser and navigate to `http://localhost:5000`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enter or paste your Markdown content in the left editor panel
|
||||
2. See the real-time conversion in the right preview panel
|
||||
3. Use the settings panel to configure document options:
|
||||
- Set document title
|
||||
- Choose default text style
|
||||
- Enable/disable auto-captioning for images, tables, and figures
|
||||
- Enable/disable table pagination
|
||||
4. Use the toolbar buttons to format text directly in the editor
|
||||
5. Select block type and apply custom styles to specific elements
|
||||
6. Add custom captions to elements as needed
|
||||
7. Click "Export to Word" to download your document
|
||||
|
||||
## Features Explained
|
||||
|
||||
### Real-time Preview
|
||||
- The application converts Markdown to Word format as you type
|
||||
- Preview updates automatically as you make changes
|
||||
|
||||
### Auto Captioning
|
||||
- Images automatically receive "Figure X" captions
|
||||
- Tables automatically receive "Table X" captions
|
||||
- Captions are numbered sequentially
|
||||
|
||||
### Table Pagination
|
||||
- When enabled, tables that span multiple pages will repeat their header row
|
||||
- This ensures readability when tables cross page boundaries
|
||||
|
||||
### Block Styling
|
||||
- Select different block types (paragraph, heading, list, etc.)
|
||||
- Apply specific styles to different content blocks
|
||||
- Add custom captions to any element
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/workspace/
|
||||
├── app.py # Main Flask application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── templates/
|
||||
│ └── index.html # Main HTML template
|
||||
└── static/
|
||||
├── style.css # CSS styling
|
||||
└── script.js # JavaScript functionality
|
||||
```
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Frontend**: HTML, CSS, JavaScript, Bootstrap 5, Marked.js
|
||||
- **Backend**: Flask (Python)
|
||||
- **Document Processing**: python-docx
|
||||
- **Markdown Processing**: markdown (Python library)
|
||||
|
||||
## Customization
|
||||
|
||||
You can customize the application by modifying:
|
||||
- `static/style.css` for visual styling
|
||||
- `static/script.js` for client-side functionality
|
||||
- `app.py` for server-side processing and document generation
|
||||
@@ -1,209 +1,93 @@
|
||||
# GhostEditor
|
||||
Программа для конвертации из формата `md` в формат `docx`(x это без макросов, m с макросами) (`Office Open XML`),`odt`(`OpenDocument`)
|
||||
# GhostEditor - Markdown to Word Converter
|
||||
|
||||
#### **1. python-docx**
|
||||
**Назначение**: Создание и модификация Word-документов.
|
||||
**Ключевые возможности**:
|
||||
- Добавление/редактирование текста, таблиц, изображений.
|
||||
- Управление стилями, колонтитулами, сносками.
|
||||
- Работа с XML через абстракции (Paragraph, Run, Table).
|
||||
A web-based application that converts Markdown to Word documents with advanced features including real-time preview, styling options, auto-captioning, and table pagination.
|
||||
|
||||
**Пример**:
|
||||
```python
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
## Features
|
||||
|
||||
doc = Document()
|
||||
paragraph = doc.add_paragraph("Hello ")
|
||||
run = paragraph.add_run("OOXML!")
|
||||
run.bold = True
|
||||
run.font.size = Pt(14)
|
||||
1. **Real-time Conversion**: See your Markdown text convert to Word format in real-time
|
||||
2. **Styling Options**: Apply different styles to text elements (headings, quotes, code blocks, etc.)
|
||||
3. **Auto Captioning**: Automatic captions for images, tables, and figures
|
||||
4. **Table Pagination**: Support for tables that span across pages with repeating headers
|
||||
5. **Custom Settings**: Configure document settings and styling preferences
|
||||
|
||||
# Таблица
|
||||
table = doc.add_table(rows=2, cols=2)
|
||||
table.cell(0, 0).text = "Cell A1"
|
||||
## Requirements
|
||||
|
||||
doc.save("report.docx")
|
||||
- Python 3.7+
|
||||
- Flask
|
||||
- python-docx
|
||||
- markdown
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install the required packages:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Run the application:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
3. Open your browser and navigate to `http://localhost:5000`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enter or paste your Markdown content in the left editor panel
|
||||
2. See the real-time conversion in the right preview panel
|
||||
3. Use the settings panel to configure document options:
|
||||
- Set document title
|
||||
- Choose default text style
|
||||
- Enable/disable auto-captioning for images, tables, and figures
|
||||
- Enable/disable table pagination
|
||||
4. Use the toolbar buttons to format text directly in the editor
|
||||
5. Select block type and apply custom styles to specific elements
|
||||
6. Add custom captions to elements as needed
|
||||
7. Click "Export to Word" to download your document
|
||||
|
||||
## Features Explained
|
||||
|
||||
### Real-time Preview
|
||||
- The application converts Markdown to Word format as you type
|
||||
- Preview updates automatically as you make changes
|
||||
|
||||
### Auto Captioning
|
||||
- Images automatically receive "Figure X" captions
|
||||
- Tables automatically receive "Table X" captions
|
||||
- Captions are numbered sequentially
|
||||
|
||||
### Table Pagination
|
||||
- When enabled, tables that span multiple pages will repeat their header row
|
||||
- This ensures readability when tables cross page boundaries
|
||||
|
||||
### Block Styling
|
||||
- Select different block types (paragraph, heading, list, etc.)
|
||||
- Apply specific styles to different content blocks
|
||||
- Add custom captions to any element
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/workspace/
|
||||
├── app.py # Main Flask application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── templates/
|
||||
│ └── index.html # Main HTML template
|
||||
└── static/
|
||||
├── style.css # CSS styling
|
||||
└── script.js # JavaScript functionality
|
||||
```
|
||||
|
||||
**Плюсы**:
|
||||
- Интуитивный API, близкий к логике Word.
|
||||
- Поддержка всех ключевых частей пакета (headers, footers, styles).
|
||||
## Technologies Used
|
||||
|
||||
**Минусы**:
|
||||
- Нет поддержки макросов.
|
||||
- Ограниченная работа с графикой (изображения добавляются, но редактирование сложно).
|
||||
- **Frontend**: HTML, CSS, JavaScript, Bootstrap 5, Marked.js
|
||||
- **Backend**: Flask (Python)
|
||||
- **Document Processing**: python-docx
|
||||
- **Markdown Processing**: markdown (Python library)
|
||||
|
||||
**Ссылка**: [python-docx.readthedocs.io](https://python-docx.readthedocs.io/)
|
||||
## Customization
|
||||
|
||||
---
|
||||
|
||||
#### **2. docxtpl**
|
||||
**Назначение**: Генерация документов по шаблонам (Jinja2).
|
||||
**Особенности**:
|
||||
- Шаблоны .docx с переменными (`{{ variable }}`) и логикой (`{% if %}`).
|
||||
- Поддержка таблиц, изображений, HTML-фрагментов.
|
||||
|
||||
**Пример**:
|
||||
```python
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
doc = DocxTemplate("template.docx")
|
||||
context = {
|
||||
"title": "Отчет за 2023",
|
||||
"items": [{"name": "Item 1"}, {"name": "Item 2"}]
|
||||
}
|
||||
doc.render(context)
|
||||
doc.save("output.docx")
|
||||
```
|
||||
|
||||
**Плюсы**:
|
||||
- Идеален для шаблонизированных отчетов (договоры, счета).
|
||||
- Интеграция с Django/Flask.
|
||||
|
||||
**Минусы**:
|
||||
- Требует предварительной настройки шаблонов в Word.
|
||||
|
||||
**Ссылка**: [github.com/elapouya/python-docx-template](https://github.com/elapouya/python-docx-template)
|
||||
|
||||
---
|
||||
|
||||
#### **3. mammoth**
|
||||
**Назначение**: Конвертация .docx → HTML с сохранением структуры.
|
||||
**Особенности**:
|
||||
- Преобразует стили Word в CSS-классы.
|
||||
- Подходит для извлечения контента из сложных документов.
|
||||
|
||||
**Пример**:
|
||||
```python
|
||||
import mammoth
|
||||
|
||||
with open("document.docx", "rb") as docx_file:
|
||||
result = mammoth.convert_to_html(docx_file)
|
||||
html = result.value # HTML-строка
|
||||
warnings = result.messages # Предупреждения
|
||||
```
|
||||
|
||||
**Плюсы**:
|
||||
- Сохраняет семантику (заголовки, списки, таблицы).
|
||||
- Легко интегрируется с парсерами (BeautifulSoup).
|
||||
|
||||
**Минусы**:
|
||||
- Не подходит для обратной конвертации (HTML → docx).
|
||||
|
||||
**Ссылка**: [github.com/mwilliamson/mammoth.py](https://github.com/mwilliamson/mammoth.py)
|
||||
|
||||
---
|
||||
|
||||
### **Низкоуровневые инструменты (для кастомных задач)**
|
||||
|
||||
#### **1. zipfile + lxml**
|
||||
**Назначение**: Прямой доступ к XML-структуре OOXML.
|
||||
**Когда использовать**:
|
||||
- При работе с нестандартными частями пакета (напр., кастомные XML-маппинги в Excel).
|
||||
- Для восстановления поврежденных файлов.
|
||||
|
||||
**Пример для Excel**:
|
||||
```python
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
|
||||
# Распаковываем workbook.xml
|
||||
with zipfile.ZipFile("report.xlsx") as xlsx:
|
||||
xml_data = xlsx.read("xl/workbook.xml")
|
||||
|
||||
# Парсим XML
|
||||
workbook = etree.fromstring(xml_data)
|
||||
namespaces = {"ns": "http://schemas.../spreadsheetml/2006/main"}
|
||||
sheets = workbook.findall(".//ns:sheet", namespaces)
|
||||
|
||||
# Меняем имя листа
|
||||
sheets[0].set("{http://.../officeDocument/2006/relationships}name", "New Sheet")
|
||||
|
||||
# Сохраняем изменения
|
||||
with zipfile.ZipFile("fixed.xlsx", "w") as new_xlsx:
|
||||
for file in xlsx.namelist():
|
||||
if file != "xl/workbook.xml":
|
||||
new_xlsx.writestr(file, xlsx.read(file))
|
||||
new_xlsx.writestr("xl/workbook.xml", etree.tostring(workbook))
|
||||
```
|
||||
|
||||
**Плюсы**:
|
||||
- Полный контроль над структурой.
|
||||
- Работает с любыми частями пакета.
|
||||
|
||||
**Минусы**:
|
||||
- Требует знания XML-схем OOXML.
|
||||
- Риск повреждения файла при ошибках.
|
||||
|
||||
---
|
||||
|
||||
#### **2. officedoc**
|
||||
**Назначение**: Универсальный доступ к форматам Office (docx, xlsx, pptx).
|
||||
**Особенности**:
|
||||
- Объединяет функционал `python-docx`, `openpyxl`, `python-pptx` в едином API.
|
||||
- Упрощает обработку мультимедийных вложений.
|
||||
|
||||
**Пример**:
|
||||
```python
|
||||
from officedoc import Document
|
||||
|
||||
doc = Document("mixed.docx")
|
||||
for image in doc.images:
|
||||
image.save("extracted_" + image.filename)
|
||||
|
||||
for table in doc.tables:
|
||||
print(table.to_dataframe()) # Конвертация в pandas DataFrame
|
||||
```
|
||||
|
||||
**Ссылка**: [github.com/mikem1701/officedoc](https://github.com/mikem1701/officedoc)
|
||||
|
||||
---
|
||||
|
||||
### **Сравнение библиотек**
|
||||
|
||||
| **Библиотека** | **Скорость** | **Сложность** | **Особенности** |
|
||||
|----------------------|-------------|---------------|----------------------------|
|
||||
| **python-docx** | Средняя | Средняя | Стандарт для работы с Word |
|
||||
| **docxtpl** | Средняя | Низкая | Шаблонизация через Jinja2 |
|
||||
| **zipfile + lxml** | Высокая | Высокая | Полный контроль над XML |
|
||||
|
||||
---
|
||||
|
||||
#### Для Word (.docx)
|
||||
- **Простое создание документов**: `python-docx`.
|
||||
- **Шаблонные документы** (договоры, счета): `docxtpl`.
|
||||
- **Извлечение контента**: `mammoth` → обработка HTML через BeautifulSoup.
|
||||
- **Редактирование структуры**: `python-docx` + `zipfile` для кастомных частей.
|
||||
|
||||
#### Для кастомных задач
|
||||
- **Восстановление файлов**: `zipfile` + `lxml` (ручное исправление XML).
|
||||
- **Интеграция с внешними системами**: `openpyxl`/`python-docx` + REST API (напр., выгрузка данных из БД в Excel).
|
||||
|
||||
---
|
||||
|
||||
### Типичные проблемы и решения
|
||||
|
||||
2. Стили не применяются в Word
|
||||
- Проблема: `python-docx` использует стили из `styles.xml`, но не создает новые автоматически.
|
||||
- Решение:
|
||||
```python
|
||||
from docx.shared import Pt
|
||||
style = doc.styles.add_style("Heading3", WD_STYLE_TYPE.PARAGRAPH)
|
||||
font = style.font
|
||||
font.name = "Arial"
|
||||
font.size = Pt(12)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Заключение**
|
||||
Для 90% задач достаточно:
|
||||
- **Excel**: `openpyxl` или `pandas`.
|
||||
- **Word**: `python-docx` + `docxtpl`.
|
||||
|
||||
**Используйте низкоуровневые методы (zipfile/lxml) только когда**:
|
||||
|
||||
*Для углубленного изучения:*
|
||||
- [Open XML SDK 2.5](https://learn.microsoft.com/ru-ru/office/open-xml/open-xml-sdk) (официальная документация Microsoft).
|
||||
- [ECMA-376 стандарт](https://www.ecma-international.org/publications-and-standards/standards/ecma-376/) (полная спецификация).
|
||||
You can customize the application by modifying:
|
||||
- `static/style.css` for visual styling
|
||||
- `static/script.js` for client-side functionality
|
||||
- `app.py` for server-side processing and document generation
|
||||
@@ -0,0 +1,324 @@
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
Flask==2.3.3
|
||||
gunicorn==23.0.0
|
||||
markdown==3.4.4
|
||||
odfdo==3.17.6
|
||||
requests
|
||||
@@ -0,0 +1,157 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const markdownInput = document.getElementById('markdown-input');
|
||||
const previewOutput = document.getElementById('preview-output');
|
||||
const stylesList = document.getElementById('styles-list');
|
||||
const exportButton = document.getElementById('export-odt');
|
||||
const addTocButton = document.getElementById('add-toc');
|
||||
const addStyleButton = document.getElementById('add-style');
|
||||
const styleEditorModal = document.getElementById('style-editor-modal');
|
||||
const styleEditorForm = document.getElementById('style-editor-form');
|
||||
const styleModalTitle = document.getElementById('style-modal-title');
|
||||
const styleNameInput = document.getElementById('style-name-input');
|
||||
const fontFamilyInput = document.getElementById('font-family-input');
|
||||
const fontSizeInput = document.getElementById('font-size-input');
|
||||
const fontWeightInput = document.getElementById('font-weight-input');
|
||||
const fontStyleInput = document.getElementById('font-style-input');
|
||||
const textAlignInput = document.getElementById('text-align-input');
|
||||
const colorInput = document.getElementById('color-input');
|
||||
const backgroundColorInput = document.getElementById('background-color-input');
|
||||
const paddingInput = document.getElementById('padding-input');
|
||||
const borderLeftInput = document.getElementById('border-left-input');
|
||||
const paddingLeftInput = document.getElementById('padding-left-input');
|
||||
const marginBottomInput = document.getElementById('margin-bottom-input');
|
||||
const closeButton = document.querySelector('.close-button');
|
||||
const boldButton = document.getElementById('bold-button');
|
||||
const italicButton = document.getElementById('italic-button');
|
||||
const h1Button = document.getElementById('h1-button');
|
||||
const h2Button = document.getElementById('h2-button');
|
||||
|
||||
let styles = {};
|
||||
let appliedStyles = {};
|
||||
|
||||
async function fetchStyles() {
|
||||
const response = await fetch('/styles');
|
||||
styles = await response.json();
|
||||
renderStyles();
|
||||
}
|
||||
|
||||
function renderStyles() {
|
||||
stylesList.innerHTML = '';
|
||||
for (const name in styles) {
|
||||
const styleItem = document.createElement('div');
|
||||
styleItem.className = 'style-item';
|
||||
styleItem.textContent = name;
|
||||
styleItem.addEventListener('click', () => applyStyleToSelection(name));
|
||||
stylesList.appendChild(styleItem);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyleToSelection(styleName) {
|
||||
const start = markdownInput.selectionStart;
|
||||
const end = markdownInput.selectionEnd;
|
||||
const selectedText = markdownInput.value.substring(start, end);
|
||||
const lines = markdownInput.value.substring(0, start).split('\n');
|
||||
const startLine = lines.length - 1;
|
||||
|
||||
for (let i = 0; i < selectedText.split('\n').length; i++) {
|
||||
appliedStyles[startLine + i] = styleName;
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function applyMarkdownFormatting(prefix, suffix = prefix) {
|
||||
const start = markdownInput.selectionStart;
|
||||
const end = markdownInput.selectionEnd;
|
||||
const selectedText = markdownInput.value.substring(start, end);
|
||||
const newText = prefix + selectedText + suffix;
|
||||
markdownInput.value = markdownInput.value.substring(0, start) + newText + markdownInput.value.substring(end);
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
boldButton.addEventListener('click', () => applyMarkdownFormatting('**'));
|
||||
italicButton.addEventListener('click', () => applyMarkdownFormatting('*'));
|
||||
h1Button.addEventListener('click', () => applyMarkdownFormatting('# '));
|
||||
h2Button.addEventListener('click', () => applyMarkdownFormatting('## '));
|
||||
|
||||
async function updatePreview() {
|
||||
const markdown = markdownInput.value;
|
||||
const response = await fetch('/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ markdown, styles: appliedStyles }),
|
||||
});
|
||||
const data = await response.json();
|
||||
previewOutput.innerHTML = data.html;
|
||||
}
|
||||
|
||||
markdownInput.addEventListener('input', updatePreview);
|
||||
|
||||
exportButton.addEventListener('click', async () => {
|
||||
const markdown = markdownInput.value;
|
||||
const response = await fetch('/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ markdown, styles: appliedStyles }),
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'document.odt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
|
||||
addTocButton.addEventListener('click', () => {
|
||||
const cursorPos = markdownInput.selectionStart;
|
||||
const textBefore = markdownInput.value.substring(0, cursorPos);
|
||||
const textAfter = markdownInput.value.substring(cursorPos);
|
||||
markdownInput.value = textBefore + '[TOC]\n' + textAfter;
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
addStyleButton.addEventListener('click', () => {
|
||||
styleModalTitle.textContent = 'Add New Style';
|
||||
styleEditorForm.reset();
|
||||
styleNameInput.value = '';
|
||||
styleEditorModal.style.display = 'block';
|
||||
});
|
||||
|
||||
closeButton.addEventListener('click', () => {
|
||||
styleEditorModal.style.display = 'none';
|
||||
});
|
||||
|
||||
styleEditorForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = styleNameInput.value;
|
||||
const style = {
|
||||
'font-family': fontFamilyInput.value,
|
||||
'font-size': fontSizeInput.value,
|
||||
'font-weight': fontWeightInput.value,
|
||||
'font-style': fontStyleInput.value,
|
||||
'text-align': textAlignInput.value,
|
||||
'color': colorInput.value,
|
||||
'background-color': backgroundColorInput.value,
|
||||
'padding': paddingInput.value,
|
||||
'border-left': borderLeftInput.value,
|
||||
'padding-left': paddingLeftInput.value,
|
||||
'margin-bottom': marginBottomInput.value,
|
||||
};
|
||||
await fetch(`/styles/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(style),
|
||||
});
|
||||
styleEditorModal.style.display = 'none';
|
||||
fetchStyles();
|
||||
});
|
||||
|
||||
fetchStyles();
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f8f9fa;
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 8px 16px;
|
||||
background-color: #edf2fa;
|
||||
border-bottom: 1px solid #dadce0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
margin-right: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background-color: #d2e3fc;
|
||||
}
|
||||
|
||||
.markdown-input {
|
||||
flex: 1;
|
||||
padding: 48px;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
flex: 1;
|
||||
padding: 48px;
|
||||
background-color: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-output {
|
||||
max-width: 8.5in;
|
||||
margin: 0 auto;
|
||||
background-color: #fff;
|
||||
padding: 1in;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.styles-pane {
|
||||
width: 250px;
|
||||
padding: 20px;
|
||||
background-color: #edf2fa;
|
||||
border-left: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
#styles-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.style-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dadce0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.style-item:hover {
|
||||
background-color: #d2e3fc;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 15% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
width: 80%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close-button:hover,
|
||||
.close-button:focus {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"Test Style": {
|
||||
"font-family": "Comic Sans MS",
|
||||
"font-size": "20pt",
|
||||
"color": "#ff00ff"
|
||||
},
|
||||
"My New Style": {
|
||||
"font-family": "Georgia",
|
||||
"font-size": "14pt",
|
||||
"font-weight": "",
|
||||
"font-style": "",
|
||||
"text-align": "",
|
||||
"color": "blue",
|
||||
"background-color": "",
|
||||
"padding": "",
|
||||
"border-left": "",
|
||||
"padding-left": "",
|
||||
"margin-bottom": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GhostEditor</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<div class="editor-pane">
|
||||
<div class="toolbar">
|
||||
<button id="bold-button" title="Bold"><i class="fas fa-bold"></i></button>
|
||||
<button id="italic-button" title="Italic"><i class="fas fa-italic"></i></button>
|
||||
<button id="h1-button" title="Heading 1">H1</button>
|
||||
<button id="h2-button" title="Heading 2">H2</button>
|
||||
<button id="add-toc" title="Insert TOC"><i class="fas fa-list-ul"></i></button>
|
||||
<button id="export-odt" title="Export as ODT"><i class="fas fa-file-export"></i></button>
|
||||
</div>
|
||||
<textarea id="markdown-input" class="markdown-input"></textarea>
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<div id="preview-output" class="preview-output"></div>
|
||||
</div>
|
||||
<div class="styles-pane">
|
||||
<h2>Styles</h2>
|
||||
<div id="styles-list"></div>
|
||||
<button id="add-style">Add New Style</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="style-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-button">×</span>
|
||||
<h2 id="style-modal-title">Edit Style</h2>
|
||||
<form id="style-editor-form">
|
||||
<label for="style-name-input">Style Name:</label>
|
||||
<input type="text" id="style-name-input">
|
||||
<label for="font-family-input">Font Family:</label>
|
||||
<input type="text" id="font-family-input">
|
||||
<label for="font-size-input">Font Size:</label>
|
||||
<input type="text" id="font-size-input">
|
||||
<label for="font-weight-input">Font Weight:</label>
|
||||
<input type="text" id="font-weight-input">
|
||||
<label for="font-style-input">Font Style:</label>
|
||||
<input type="text" id="font-style-input">
|
||||
<label for="text-align-input">Text Align:</label>
|
||||
<input type="text" id="text-align-input">
|
||||
<label for="color-input">Color:</label>
|
||||
<input type="text" id="color-input">
|
||||
<label for="background-color-input">Background Color:</label>
|
||||
<input type="text" id="background-color-input">
|
||||
<label for="padding-input">Padding:</label>
|
||||
<input type="text" id="padding-input">
|
||||
<label for="border-left-input">Border Left:</label>
|
||||
<input type="text" id="border-left-input">
|
||||
<label for="padding-left-input">Padding Left:</label>
|
||||
<input type="text" id="padding-left-input">
|
||||
<label for="margin-bottom-input">Margin Bottom:</label>
|
||||
<input type="text" id="margin-bottom-input">
|
||||
<button type="submit">Save Style</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user