271 lines
8.3 KiB
Python
271 lines
8.3 KiB
Python
import threading
|
|
import uuid
|
|
from collections import OrderedDict
|
|
from html.parser import HTMLParser
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import streamlit as st
|
|
|
|
try:
|
|
from app.converter import convert
|
|
from app.version import __version__
|
|
except ModuleNotFoundError:
|
|
sys.path.append(str(Path(__file__).resolve().parent.parent))
|
|
from app.converter import convert
|
|
from app.version import __version__
|
|
|
|
MAX_PREVIEW_STORE_ITEMS = 20
|
|
|
|
|
|
class BodyInnerHTMLParser(HTMLParser):
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=False)
|
|
self._inside_body = False
|
|
self._depth = 0
|
|
self._parts: list[str] = []
|
|
|
|
def handle_starttag(self, tag: str, attrs) -> None:
|
|
rendered = self.get_starttag_text()
|
|
if tag == "body":
|
|
self._inside_body = True
|
|
self._depth = 0
|
|
return
|
|
if self._inside_body and rendered is not None:
|
|
self._parts.append(rendered)
|
|
self._depth += 1
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag == "body" and self._inside_body:
|
|
self._inside_body = False
|
|
self._depth = 0
|
|
return
|
|
if self._inside_body:
|
|
self._parts.append(f"</{tag}>")
|
|
if self._depth > 0:
|
|
self._depth -= 1
|
|
|
|
def handle_startendtag(self, tag: str, attrs) -> None:
|
|
if self._inside_body:
|
|
rendered = self.get_starttag_text()
|
|
if rendered is not None:
|
|
self._parts.append(rendered)
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if self._inside_body:
|
|
self._parts.append(data)
|
|
|
|
def handle_entityref(self, name: str) -> None:
|
|
if self._inside_body:
|
|
self._parts.append(f"&{name};")
|
|
|
|
def handle_charref(self, name: str) -> None:
|
|
if self._inside_body:
|
|
self._parts.append(f"&#{name};")
|
|
|
|
def handle_comment(self, data: str) -> None:
|
|
if self._inside_body:
|
|
self._parts.append(f"<!--{data}-->")
|
|
|
|
def body_html(self) -> str:
|
|
return "".join(self._parts).strip()
|
|
|
|
|
|
def extract_body_html(document_html: str) -> str:
|
|
parser = BodyInnerHTMLParser()
|
|
parser.feed(document_html)
|
|
parser.close()
|
|
return parser.body_html()
|
|
|
|
|
|
@st.cache_resource
|
|
def get_preview_runtime() -> dict[str, object]:
|
|
store: OrderedDict[str, str] = OrderedDict()
|
|
lock = threading.Lock()
|
|
|
|
class PreviewHandler(BaseHTTPRequestHandler):
|
|
def do_GET(self) -> None:
|
|
prefix = "/preview/"
|
|
if not self.path.startswith(prefix):
|
|
self.send_error(404)
|
|
return
|
|
|
|
preview_id = self.path[len(prefix) :].split("?", 1)[0]
|
|
with lock:
|
|
document_html = store.get(preview_id)
|
|
|
|
if document_html is None:
|
|
self.send_error(404)
|
|
return
|
|
|
|
payload = document_html.encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def log_message(self, format: str, *args) -> None:
|
|
return
|
|
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), PreviewHandler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
return {
|
|
"base_url": f"http://127.0.0.1:{server.server_port}",
|
|
"store": store,
|
|
"lock": lock,
|
|
}
|
|
|
|
|
|
def register_preview(document_html: str) -> str:
|
|
runtime = get_preview_runtime()
|
|
preview_id = uuid.uuid4().hex
|
|
store = runtime["store"]
|
|
lock = runtime["lock"]
|
|
|
|
with lock:
|
|
store[preview_id] = document_html
|
|
while len(store) > MAX_PREVIEW_STORE_ITEMS:
|
|
store.popitem(last=False)
|
|
|
|
return f"{runtime['base_url']}/preview/{preview_id}"
|
|
|
|
|
|
st.set_page_config(
|
|
page_title="Markdown to HTML",
|
|
page_icon=":material/description:",
|
|
layout="centered",
|
|
)
|
|
|
|
if "html_result" not in st.session_state:
|
|
st.session_state["html_result"] = None
|
|
if "output_name" not in st.session_state:
|
|
st.session_state["output_name"] = "document.html"
|
|
if "preview_url" not in st.session_state:
|
|
st.session_state["preview_url"] = None
|
|
|
|
st.title("Markdown → HTML")
|
|
st.caption(
|
|
f"Версия {__version__}. Загрузите markdown-файл или вставьте текст, проверьте превью и скачайте готовый HTML."
|
|
)
|
|
|
|
input_mode = st.segmented_control(
|
|
"Источник Markdown",
|
|
options=["Файл", "Текст"],
|
|
default="Файл",
|
|
)
|
|
|
|
uploaded_file = None
|
|
pasted_markdown = ""
|
|
|
|
if input_mode == "Файл":
|
|
uploaded_file = st.file_uploader(
|
|
"Загрузите .md файл",
|
|
type=["md", "markdown"],
|
|
)
|
|
else:
|
|
pasted_markdown = st.text_area(
|
|
"Вставьте Markdown из буфера обмена",
|
|
placeholder="# Заголовок\n\nВставьте сюда markdown-текст.",
|
|
height=260,
|
|
)
|
|
|
|
html_result = st.session_state["html_result"]
|
|
is_convert_disabled = (
|
|
uploaded_file is None if input_mode == "Файл" else not pasted_markdown.strip()
|
|
)
|
|
|
|
with st.container(border=True):
|
|
action_col, preview_col, download_col = st.columns(
|
|
[1.1, 1, 1],
|
|
vertical_alignment="center",
|
|
)
|
|
|
|
with action_col:
|
|
convert_clicked = st.button(
|
|
"Конвертировать",
|
|
disabled=is_convert_disabled,
|
|
type="primary",
|
|
icon=":material/auto_awesome:",
|
|
use_container_width=True,
|
|
)
|
|
|
|
with preview_col:
|
|
if html_result and st.session_state["preview_url"] is not None:
|
|
st.link_button(
|
|
"Открыть превью",
|
|
url=st.session_state["preview_url"],
|
|
icon=":material/open_in_new:",
|
|
use_container_width=True,
|
|
)
|
|
else:
|
|
st.button(
|
|
"Открыть превью",
|
|
disabled=True,
|
|
icon=":material/open_in_new:",
|
|
use_container_width=True,
|
|
)
|
|
|
|
with download_col:
|
|
if html_result:
|
|
st.download_button(
|
|
"Скачать HTML",
|
|
data=html_result,
|
|
file_name=st.session_state["output_name"],
|
|
mime="text/html",
|
|
icon=":material/download:",
|
|
use_container_width=True,
|
|
)
|
|
else:
|
|
st.button(
|
|
"Скачать HTML",
|
|
disabled=True,
|
|
icon=":material/download:",
|
|
use_container_width=True,
|
|
)
|
|
|
|
if html_result:
|
|
st.caption(":green-badge[Результат готов]")
|
|
else:
|
|
st.caption("После конвертации здесь появятся действия с готовым файлом.")
|
|
|
|
if convert_clicked and not is_convert_disabled:
|
|
if input_mode == "Файл":
|
|
markdown_bytes = uploaded_file.getvalue()
|
|
markdown_text = markdown_bytes.decode("utf-8")
|
|
fallback_title = Path(uploaded_file.name).stem or "Document"
|
|
output_name = f"{fallback_title}.html"
|
|
else:
|
|
markdown_text = pasted_markdown
|
|
fallback_title = "Document"
|
|
output_name = "document.html"
|
|
|
|
try:
|
|
st.session_state["html_result"] = convert(
|
|
markdown_text,
|
|
fallback_title=fallback_title,
|
|
)
|
|
st.session_state["output_name"] = output_name
|
|
st.session_state["preview_url"] = register_preview(st.session_state["html_result"])
|
|
st.rerun()
|
|
except RuntimeError as exc:
|
|
st.session_state["html_result"] = None
|
|
st.session_state["preview_url"] = None
|
|
st.error(str(exc))
|
|
|
|
html_result = st.session_state["html_result"]
|
|
if html_result:
|
|
body_html = extract_body_html(html_result)
|
|
|
|
with st.container(border=True):
|
|
st.caption(
|
|
"Inline-превью без стилей. Для точного вида — «Открыть превью» или скачайте файл."
|
|
)
|
|
st.markdown(body_html, unsafe_allow_html=True)
|
|
|
|
with st.expander("Показать исходный HTML", icon=":material/code:"):
|
|
st.code(html_result, language="html")
|