ricklon Claude Sonnet 4.6 commited on
Commit
dc85ea9
·
1 Parent(s): 6b7d576

Replace browser-side KaTeX/MathJax with server-side MathML via latex2mathml

Browse files

Every 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>

Files changed (2) hide show
  1. app.py +81 -26
  2. requirements.txt +1 -1
app.py CHANGED
@@ -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
- def to_math_md(text):
116
- """Convert model output to markdown with $$/$ delimiters for gr.Markdown + KaTeX."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  if not text:
118
  return ""
119
- # Collapse all whitespace inside \[...\] to a single line before converting to
120
- # $$content$$. This fixes two problems:
121
- # 1. Markdown parsers that don't recognise multi-line $$ ... $$ blocks.
122
- # 2. LaTeX \\ (line-break inside aligned) being processed by the markdown
123
- # engine as a hard line-break (<br>) before KaTeX ever sees it, which
124
- # breaks every aligned/cases/gathered environment.
125
- # Note: \\ is two backslash characters — not whitespace — so it survives split().
126
- text = re.sub(r'\\\[(.+?)\\\]',
127
- lambda m: f'\n\n$${" ".join(m.group(1).split())}$$\n\n',
128
- text, flags=re.DOTALL)
129
- # Remove any orphaned \[ without a closing \] (model output truncated mid-equation).
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
- # Inline math
134
- text = re.sub(r'\\\((.+?)\\\)', lambda m: f'${m.group(1).strip()}$', text)
135
- return text
 
 
 
 
 
 
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.Markdown(
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, to_math_md(markdown), raw, img_out, crops,
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
  )
requirements.txt CHANGED
@@ -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
- pymdown-extensions
 
10
  PyMuPDF
11
  hf_transfer
12
  markdown
13
+ latex2mathml