Skip to content

Commit e7ce5ed

Browse files
xjhazxjhazCopilotalephpi
authored
Mathml conversion support (#13)
* Add mathml support * Fix MathML output for Word by using KaTeX and normalizing spaces * fix: sanitize MathML to avoid Word whitespace artifacts * detale temml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add jsdoc * doc --------- Co-authored-by: xjhaz <xjh67@foxmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: alephpi <maosicheng98@gmail.com>
1 parent 7a7a41d commit e7ce5ed

7 files changed

Lines changed: 162 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The web application for [Texo](https://github.com/alephpi/Texo). Built with Nuxt
1616
- [ ] streaming output
1717
- [x] pwa
1818
- [x] typst conversion
19+
- [x] mathml conversion (https://github.com/alephpi/Texo-web/pull/13)
1920
- [x] WYSIWYG editor
2021

2122
## Acknowledgement

app/composables/textProcessor.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import katex from 'katex'
12
import { tex2typst } from 'tex2typst'
23

34
/**
@@ -44,3 +45,91 @@ export function convertToTypst(code: string) {
4445
const cleanedCode = code.replace(/~/g, '\\ ')
4546
return tex2typst(cleanedCode)
4647
}
48+
49+
/**
50+
* Sanitizes MathML content for compatibility with Microsoft Word.
51+
*
52+
* Removes layout hacks and spacing elements that Word renders incorrectly as blank boxes.
53+
* Handles both server-side (regex-based) and client-side (DOM-based) sanitization.
54+
*
55+
* @param mathml - The MathML string to sanitize
56+
* @returns The sanitized MathML string. Returns the original input if parsing fails or if DOM APIs are unavailable.
57+
*
58+
* @remarks
59+
* - If `DOMParser` and `XMLSerializer` are unavailable (server-side), uses regex patterns to remove:
60+
* - All `<mpadded>` elements
61+
* - `<mspace>` elements with width="1em" or width="1.0em"
62+
* - If DOM APIs are available (client-side), parses the MathML and:
63+
* - Unwraps `<mpadded>` elements by moving their children to the parent
64+
* - Removes `<mspace>` elements with width >= 1em
65+
*
66+
* @example
67+
* ```ts
68+
* const original = '<math><mpadded><mi>x</mi></mpadded></math>';
69+
* const sanitized = sanitizeMathMLForWord(original);
70+
* // Returns: '<math><mi>x</mi></math>'
71+
* ```
72+
*/
73+
function sanitizeMathMLForWord(mathml: string): string {
74+
if (!mathml) return mathml
75+
76+
// Strip layout hacks that Word renders as blank boxes.
77+
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') {
78+
return mathml
79+
.replace(/<mpadded[\s\S]*?<\/mpadded>/g, '')
80+
.replace(
81+
/<mspace\b[^>]*\bwidth=(['"])?1(?:\.0+)?em\1[^>]*>[\s\S]*?<\/mspace>/g,
82+
''
83+
)
84+
.replace(/<mspace\b[^>]*\bwidth=(['"])?1(?:\.0+)?em\1[^>]*\/>/g, '')
85+
}
86+
87+
const doc = new DOMParser().parseFromString(mathml, 'application/xml')
88+
const root = doc.documentElement
89+
if (!root || root.nodeName === 'parsererror') return mathml
90+
91+
root.querySelectorAll('mpadded').forEach((node) => {
92+
const parent = node.parentNode
93+
if (!parent) return
94+
while (node.firstChild) {
95+
parent.insertBefore(node.firstChild, node)
96+
}
97+
parent.removeChild(node)
98+
})
99+
100+
root.querySelectorAll('mspace').forEach((node) => {
101+
const width = node.getAttribute('width')
102+
if (!width) return
103+
const match = width.trim().match(/^([0-9]*\.?[0-9]+)em$/)
104+
if (!match) return
105+
const value = Number(match[1])
106+
if (Number.isFinite(value) && value >= 1) {
107+
node.remove()
108+
}
109+
})
110+
111+
return new XMLSerializer().serializeToString(root)
112+
}
113+
114+
export function convertToMathML(code: string) {
115+
const cleanedCode = code.trim()
116+
if (!cleanedCode) return ''
117+
118+
const rendered = katex.renderToString(cleanedCode, {
119+
throwOnError: false,
120+
displayMode: true,
121+
output: 'mathml'
122+
})
123+
124+
const mathmlMatch = rendered.match(/<math[\s\S]*<\/math>/)
125+
if (!mathmlMatch) return rendered
126+
127+
let mathml = mathmlMatch[0]
128+
mathml = mathml.replace(/<annotation[\s\S]*?<\/annotation>/g, '')
129+
mathml = mathml.replace(/<\/?semantics[^>]*>/g, '')
130+
mathml = mathml.replace(
131+
/<mtext>([\s\u00A0\u2000-\u200A\u202F\u205F\u3000]+)<\/mtext>/g,
132+
'<mspace width="0.2em"/>'
133+
)
134+
return sanitizeMathMLForWord(mathml)
135+
}

app/pages/ocr.vue

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,27 @@ async function copyAsTypst() {
9999
}
100100
}
101101
102+
async function copyAsMathML() {
103+
try {
104+
const mathmlCode = convertToMathML(latexCode.value)
105+
await navigator.clipboard.writeText(mathmlCode)
106+
toast?.add({
107+
title: t('mathmlCode') + ' ' + t('copied'),
108+
color: 'success',
109+
duration: 1500
110+
})
111+
} catch (err) {
112+
console.log(err)
113+
toast?.add({
114+
title: t('convert_to') + ' ' + t('mathmlCode') + ' ' + t('failed'),
115+
description: String(err),
116+
color: 'error',
117+
duration: 0,
118+
progress: false
119+
})
120+
}
121+
}
122+
102123
const imageFile = ref<File | null>(null)
103124
const imgHolder = ref(null)
104125
@@ -116,6 +137,9 @@ async function onFileChange(newFile: File | null | undefined) {
116137
case 'typst':
117138
await copyAsTypst()
118139
break
140+
case 'mathml':
141+
await copyAsMathML()
142+
break
119143
case 'latex':
120144
await copy()
121145
break
@@ -190,7 +214,7 @@ const preview_items = ref<TabsItem[]>([
190214
const preview_item = ref<'KaTeX' | 'Mathlive'>('KaTeX')
191215
192216
// auto copy
193-
const auto_copy_items = ref(['latex', 'typst', ...wrap_format_options])
217+
const auto_copy_items = ref(['latex', 'typst', 'mathml', ...wrap_format_options])
194218
const auto_copy_value = ref('latex')
195219
196220
let load: (model_config: ModelConfig) => Promise<void>
@@ -461,6 +485,14 @@ onBeforeUnmount(() => {
461485
>
462486
{{ t('copyAs') + ' ' + t('typstCode') }}
463487
</UButton>
488+
<UButton
489+
:disabled="!latexCode"
490+
icon="i-carbon-code"
491+
size="sm"
492+
@click="copyAsMathML"
493+
>
494+
{{ t('copyAs') + ' ' + t('mathmlCode') }}
495+
</UButton>
464496
</div>
465497
<div class="flex">
466498
<UFieldGroup>

i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"clear": "Clear",
4444
"copyAs": "Copy as",
4545
"typstCode": "Typst",
46+
"mathmlCode": "MathML",
4647
"convert_to": "Convert to ",
4748
"failed": "failed",
4849
"autoCopy": "Auto copy",
@@ -55,4 +56,4 @@
5556
"recognize_success": "Recognition succeed",
5657
"recognize_failed": "Recognition failed",
5758
"unknown_error": "Unknown error, please check console log"
58-
}
59+
}

i18n/locales/zh-CN.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"clear": "清除",
4444
"copyAs": "复制为",
4545
"typstCode": "Typst",
46+
"mathmlCode": "MathML",
4647
"convert_to": "转换为",
4748
"failed": "失败",
4849
"autoCopy": "自动复制",
@@ -55,4 +56,4 @@
5556
"recognize_success": "识别成功",
5657
"recognize_failed": "识别失败",
5758
"unknown_error": "未知错误,请查看控制台日志"
58-
}
59+
}

nuxt.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// https://nuxt.com/docs/api/configuration/nuxt-config
22
import copyKatexFonts from './utils/copy-katex-font'
3+
import mirrorPublicAssets from './utils/mirror-public-assets'
34
import replace from '@rollup/plugin-replace'
45
import { execSync } from 'child_process'
6+
import { resolve } from 'path'
7+
8+
let nitroOutputDirs: { publicDir?: string, serverDir?: string } = {}
59

610
export default defineNuxtConfig({
711
modules: [
@@ -39,6 +43,22 @@ export default defineNuxtConfig({
3943
hooks: {
4044
ready: (nuxt) => {
4145
copyKatexFonts(nuxt.options.rootDir)
46+
},
47+
'nitro:build:public-assets': (nitro) => {
48+
const publicDir = nitro?.options?.output?.publicDir
49+
const serverDir = nitro?.options?.output?.serverDir
50+
nitroOutputDirs = { publicDir, serverDir }
51+
if (publicDir && serverDir) {
52+
mirrorPublicAssets(publicDir, resolve(serverDir))
53+
}
54+
},
55+
'build:done': () => {
56+
if (nitroOutputDirs.publicDir && nitroOutputDirs.serverDir) {
57+
mirrorPublicAssets(nitroOutputDirs.publicDir, resolve(nitroOutputDirs.serverDir))
58+
return
59+
}
60+
const outputDir = resolve(process.cwd(), '.output')
61+
mirrorPublicAssets(resolve(outputDir, 'public'), resolve(outputDir, 'server'))
4262
}
4363
},
4464
eslint: {

utils/mirror-public-assets.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { cpSync, existsSync, mkdirSync } from 'fs'
2+
import { resolve } from 'path'
3+
4+
export default function mirrorPublicAssets(publicDir: string, serverDir: string) {
5+
if (!publicDir || !serverDir) return
6+
7+
const sourceDir = resolve(publicDir)
8+
const targetDir = resolve(serverDir, 'chunks', 'public')
9+
10+
if (!existsSync(sourceDir)) return
11+
if (sourceDir === targetDir) return
12+
13+
mkdirSync(targetDir, { recursive: true })
14+
cpSync(sourceDir, targetDir, { recursive: true })
15+
}

0 commit comments

Comments
 (0)