最近,微软开源了一个名为 MarkItDown 的程序,可以将 Office 文件转换为 Markdown 格式。这个项目一经发布就迅速登上了 GitHub 热门榜。
然而,由于 MarkItDown 是一个 Python 程序,对于非技术用户来说使用起来可能有些困难。为了解决这个问题,我想到了利用 WebAssembly 技术在浏览器中直接运行 Python 代码。
在浏览器内运行 Python 的开源程序是 Pyodide,使用 WebAssembly 移植了 CPython,所以 Python 的语法都是支持的。 Cloudflare 的 Python Worker 也使用的 Pyodide。
Pyodide 是 CPython 的一个移植版本,用于 WebAssembly/Emscripten。
Pyodide 使得在浏览器中使用 micropip 安装和运行 Python 包成为可能。任何在 PyPi 上有可用 wheel 文件的纯 Python 包都被支持。
许多具有 C 扩展的包也已被移植以供 Pyodide 使用。这些包括许多通用包,如 regex、PyYAML、lxml,以及包括 NumPy、pandas、SciPy、Matplotlib 和 scikit-learn 在内的科学 Python 包。Pyodide 配备了强大的 JavaScript ⟺ Python 外部函数接口,使得您可以在代码中自由地混合这两种语言,几乎没有摩擦。这包括对错误处理、async/await 的全面支持,以及更多功能。
在浏览器中使用时,Python 可以完全访问 Web API。
尝试了一下运行 MarkItDown 没想到异常的顺利,看来 WebAssembly 真的是浏览器的未来。
遇到的主要挑战和解决方案:
-
文件传输问题:如何将用户选择的文件传递给 Worker 中的 Python 运行时?
- 解决方案:利用 Pyodide 提供的方案,将浏览器文件转换为 ArrayBuffer,然后写入 Emscripten 文件系统的本地缓存。
-
依赖安装问题:PyPI 在中国大陆访问受限。
- 解决方案:使用 Cloudflare 搭建 PyPI 镜像,详见:Cloudflare PyPI Mirror。
最终,我们成功实现了一个完全运行在浏览器中的 MarkItDown 工具。欢迎访问 Office File to Markdown 进行体验。
最后放出一下 Worker 中运行 Python 的核心代码:
// eslint-disable-next-line no-undef
importScripts('https://testingcf.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js')
// npmmirror 支持 pyodide ,但是不支持 pyodide 下的 zip 包
// importScripts('https://registry.npmmirror.com/pyodide/0.26.4/files/pyodide.js')
async function loadPyodideAndPackages() {
// eslint-disable-next-line no-undef
const pyodide = await loadPyodide()
globalThis.pyodide = pyodide
await pyodide.loadPackage('micropip')
const micropip = pyodide.pyimport('micropip')
// 需要支持 PEP 691 和跨域, 目前 tuna 支持 PEP 691,但不支持跨域 https://github.com/tuna/issues/issues/2092
// micropip.set_index_urls([
// 'https://pypi.your.domains/pypi/simple',
// ])
await micropip.install('markitdown==0.0.1a2')
}
const pyodideReadyPromise = loadPyodideAndPackages()
globalThis.onmessage = async (event) => {
await pyodideReadyPromise
const file = event.data
try {
console.log('file', file)
const startTime = Date.now()
globalThis.pyodide.FS.writeFile(`/${file.filename}`, file.buffer)
await globalThis.pyodide.runPythonAsync(`
from markitdown import MarkItDown
markitdown = MarkItDown()
result = markitdown.convert("/${file.filename}")
print(result.text_content)
with open("/${file.filename}.md", "w") as file:
file.write(result.text_content)
`)
globalThis.postMessage({
filename: `${file.filename}.md`,
content: globalThis.pyodide.FS.readFile(`/${file.filename}.md`, { encoding: 'utf8' }),
time: Date.now() - startTime,
})
}
catch (error) {
globalThis.postMessage({ error: error.message || 'convert error', filename: file.filename })
}
}