Spaces:
Running on Zero
Replace browser-side KaTeX/MathJax with server-side MathML via latex2mathml
Browse filesEvery browser-side approach failed in Gradio's rendering pipeline:
- MathJax: JS typeset callback couldn't reach elements in shadow DOM
- gr.Markdown + KaTeX: markdown engine processed \\ (LaTeX line-break)
as <br> before KaTeX saw the content, breaking aligned environments
New approach: convert LaTeX to MathML in Python before the output ever
reaches the browser. MathML is a W3C standard rendered natively by all
modern browsers — no JavaScript, no CDN, no timing callbacks.
- Add _to_mathml(): converts LaTeX via latex2mathml, graceful fallback
to <code> block for unsupported commands
- Replace to_math_md() with to_math_html(): MathML blocks are embedded
as <div class="math-display"> before the markdown pass, so Python-
Markdown treats them as raw HTML blocks and leaves them untouched
- Orphaned \[ (truncated model output) stripped before markdown pass
- Restore gr.HTML + PREVIEW_CSS for styled output; remove gr.Markdown
latex_delimiters approach entirely
- requirements.txt: swap pymdown-extensions for latex2mathml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.py +81 -26
- requirements.txt +1 -1
|
@@ -11,6 +11,8 @@ import fitz
|
|
| 11 |
import re
|
| 12 |
import numpy as np
|
| 13 |
import base64
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from io import StringIO, BytesIO
|
| 16 |
|
|
@@ -112,27 +114,85 @@ def clean_output(text, include_images=False):
|
|
| 112 |
|
| 113 |
return text.strip()
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if not text:
|
| 118 |
return ""
|
| 119 |
-
|
| 120 |
-
#
|
| 121 |
-
#
|
| 122 |
-
#
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
# Remove
|
| 130 |
-
# After the substitution above all complete pairs are gone, so any remaining \[
|
| 131 |
-
# is unclosed and would appear as raw LaTeX in the output.
|
| 132 |
text = re.sub(r'\\\[.*', '', text, flags=re.DOTALL)
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
def embed_images(markdown, crops):
|
| 138 |
if not crops:
|
|
@@ -300,13 +360,7 @@ with gr.Blocks(title="DeepSeek-OCR-2") as demo:
|
|
| 300 |
with gr.Tab("Text", id="tab_text"):
|
| 301 |
text_out = gr.Textbox(lines=20, buttons=["copy"], show_label=False)
|
| 302 |
with gr.Tab("Markdown Preview", id="tab_markdown"):
|
| 303 |
-
md_out = gr.
|
| 304 |
-
"",
|
| 305 |
-
latex_delimiters=[
|
| 306 |
-
{"left": "$$", "right": "$$", "display": True},
|
| 307 |
-
{"left": "$", "right": "$", "display": False},
|
| 308 |
-
],
|
| 309 |
-
)
|
| 310 |
with gr.Tab("Boxes", id="tab_boxes"):
|
| 311 |
img_out = gr.Image(type="pil", height=500, show_label=False)
|
| 312 |
with gr.Tab("Cropped Images", id="tab_crops"):
|
|
@@ -379,7 +433,7 @@ with gr.Blocks(title="DeepSeek-OCR-2") as demo:
|
|
| 379 |
dl_tmp.write(cleaned)
|
| 380 |
dl_tmp.close()
|
| 381 |
|
| 382 |
-
return (text_display,
|
| 383 |
gr.DownloadButton(value=dl_tmp.name, visible=True))
|
| 384 |
|
| 385 |
submit_event = btn.click(run, [input_img, file_in, task, prompt, page_selector],
|
|
@@ -393,5 +447,6 @@ if __name__ == "__main__":
|
|
| 393 |
demo.queue(max_size=20).launch(
|
| 394 |
theme=gr.themes.Soft(),
|
| 395 |
server_name="0.0.0.0" if local else None,
|
|
|
|
| 396 |
ssr_mode=False, # SSR is experimental in Gradio 6 and breaks HF Spaces routing
|
| 397 |
)
|
|
|
|
| 11 |
import re
|
| 12 |
import numpy as np
|
| 13 |
import base64
|
| 14 |
+
import markdown as md_lib
|
| 15 |
+
import latex2mathml.converter
|
| 16 |
|
| 17 |
from io import StringIO, BytesIO
|
| 18 |
|
|
|
|
| 114 |
|
| 115 |
return text.strip()
|
| 116 |
|
| 117 |
+
PREVIEW_CSS = """
|
| 118 |
+
<style>
|
| 119 |
+
.math-preview {
|
| 120 |
+
padding: 1.5em;
|
| 121 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 122 |
+
font-size: 15px;
|
| 123 |
+
line-height: 1.8;
|
| 124 |
+
color: #1a1a1a;
|
| 125 |
+
max-width: 100%;
|
| 126 |
+
overflow-x: auto;
|
| 127 |
+
}
|
| 128 |
+
.math-display {
|
| 129 |
+
text-align: center;
|
| 130 |
+
overflow-x: auto;
|
| 131 |
+
margin: 1em 0;
|
| 132 |
+
padding: 0.5em 0;
|
| 133 |
+
}
|
| 134 |
+
math[display="block"] { display: block; overflow-x: auto; max-width: 100%; }
|
| 135 |
+
.math-preview h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.4em; border-bottom: 2px solid #e0e0e0; padding-bottom: 0.3em; }
|
| 136 |
+
.math-preview h2 { font-size: 1.4em; font-weight: 600; margin: 1em 0 0.4em; border-bottom: 1px solid #e0e0e0; padding-bottom: 0.2em; }
|
| 137 |
+
.math-preview h3 { font-size: 1.15em; font-weight: 600; margin: 0.9em 0 0.3em; }
|
| 138 |
+
.math-preview p { margin: 0.6em 0; }
|
| 139 |
+
.math-preview ul, .math-preview ol { padding-left: 1.8em; margin: 0.5em 0; }
|
| 140 |
+
.math-preview li { margin: 0.25em 0; }
|
| 141 |
+
.math-preview table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.95em; }
|
| 142 |
+
.math-preview th, .math-preview td { border: 1px solid #ccc; padding: 0.45em 0.75em; text-align: left; }
|
| 143 |
+
.math-preview th { background: #f2f2f2; font-weight: 600; }
|
| 144 |
+
.math-preview tr:nth-child(even) { background: #fafafa; }
|
| 145 |
+
.math-preview code { background: #f4f4f4; padding: 0.15em 0.4em; border-radius: 3px; font-family: 'Courier New', monospace; font-size: 0.88em; }
|
| 146 |
+
.math-preview pre { background: #f4f4f4; padding: 1em; border-radius: 5px; overflow-x: auto; margin: 0.8em 0; }
|
| 147 |
+
.math-preview pre code { background: none; padding: 0; }
|
| 148 |
+
.math-preview blockquote { border-left: 4px solid #ccc; margin: 0.8em 0; padding: 0.4em 1em; color: #555; background: #fafafa; }
|
| 149 |
+
.math-preview img { max-width: 100%; height: auto; display: block; margin: 0.8em 0; }
|
| 150 |
+
.math-fallback { color: #888; font-style: italic; }
|
| 151 |
+
</style>
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
def _to_mathml(latex: str, display: bool) -> str:
|
| 155 |
+
"""Convert a LaTeX string to MathML. Falls back to a code block on error."""
|
| 156 |
+
try:
|
| 157 |
+
mathml = latex2mathml.converter.convert(latex)
|
| 158 |
+
if display:
|
| 159 |
+
mathml = re.sub(r'<math\b', '<math display="block"', mathml, count=1)
|
| 160 |
+
return mathml
|
| 161 |
+
except Exception:
|
| 162 |
+
if display:
|
| 163 |
+
return f'<pre class="math-fallback"><code>{latex}</code></pre>'
|
| 164 |
+
return f'<code class="math-fallback">{latex}</code>'
|
| 165 |
+
|
| 166 |
+
def to_math_html(text: str) -> str:
|
| 167 |
+
"""Convert model markdown output to HTML with server-side MathML rendering.
|
| 168 |
+
|
| 169 |
+
LaTeX is converted to MathML by latex2mathml (pure Python, no JS required).
|
| 170 |
+
The MathML is embedded directly in the HTML before the markdown pass so the
|
| 171 |
+
markdown engine never touches LaTeX backslashes or delimiters.
|
| 172 |
+
"""
|
| 173 |
if not text:
|
| 174 |
return ""
|
| 175 |
+
|
| 176 |
+
# --- display math \[...\] → block MathML wrapped in <div> ---
|
| 177 |
+
# Insert as a proper block (blank lines around the div) so Python-Markdown
|
| 178 |
+
# treats it as a raw HTML block and leaves it untouched.
|
| 179 |
+
def display_block(m):
|
| 180 |
+
mathml = _to_mathml(m.group(1).strip(), display=True)
|
| 181 |
+
return f'\n\n<div class="math-display">{mathml}</div>\n\n'
|
| 182 |
+
|
| 183 |
+
text = re.sub(r'\\\[(.+?)\\\]', display_block, text, flags=re.DOTALL)
|
| 184 |
+
|
| 185 |
+
# Remove orphaned \[ with no matching \] (truncated model output).
|
|
|
|
|
|
|
| 186 |
text = re.sub(r'\\\[.*', '', text, flags=re.DOTALL)
|
| 187 |
+
|
| 188 |
+
# --- inline math \(...\) → inline MathML ---
|
| 189 |
+
text = re.sub(r'\\\((.+?)\\\)',
|
| 190 |
+
lambda m: _to_mathml(m.group(1).strip(), display=False),
|
| 191 |
+
text)
|
| 192 |
+
|
| 193 |
+
# --- remaining text: standard markdown (tables, bold, headings, images…) ---
|
| 194 |
+
html = md_lib.markdown(text, extensions=['tables', 'fenced_code', 'sane_lists'])
|
| 195 |
+
return f'<div class="math-preview">{html}</div>'
|
| 196 |
|
| 197 |
def embed_images(markdown, crops):
|
| 198 |
if not crops:
|
|
|
|
| 360 |
with gr.Tab("Text", id="tab_text"):
|
| 361 |
text_out = gr.Textbox(lines=20, buttons=["copy"], show_label=False)
|
| 362 |
with gr.Tab("Markdown Preview", id="tab_markdown"):
|
| 363 |
+
md_out = gr.HTML("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
with gr.Tab("Boxes", id="tab_boxes"):
|
| 365 |
img_out = gr.Image(type="pil", height=500, show_label=False)
|
| 366 |
with gr.Tab("Cropped Images", id="tab_crops"):
|
|
|
|
| 433 |
dl_tmp.write(cleaned)
|
| 434 |
dl_tmp.close()
|
| 435 |
|
| 436 |
+
return (text_display, to_math_html(markdown), raw, img_out, crops,
|
| 437 |
gr.DownloadButton(value=dl_tmp.name, visible=True))
|
| 438 |
|
| 439 |
submit_event = btn.click(run, [input_img, file_in, task, prompt, page_selector],
|
|
|
|
| 447 |
demo.queue(max_size=20).launch(
|
| 448 |
theme=gr.themes.Soft(),
|
| 449 |
server_name="0.0.0.0" if local else None,
|
| 450 |
+
head=PREVIEW_CSS,
|
| 451 |
ssr_mode=False, # SSR is experimental in Gradio 6 and breaks HF Spaces routing
|
| 452 |
)
|
|
@@ -10,4 +10,4 @@ flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7
|
|
| 10 |
PyMuPDF
|
| 11 |
hf_transfer
|
| 12 |
markdown
|
| 13 |
-
|
|
|
|
| 10 |
PyMuPDF
|
| 11 |
hf_transfer
|
| 12 |
markdown
|
| 13 |
+
latex2mathml
|