在浏览器中轻松运行 Python 程序

最近,微软开源了一个名为 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 真的是浏览器的未来。

遇到的主要挑战和解决方案:

  1. 文件传输问题:如何将用户选择的文件传递给 Worker 中的 Python 运行时?

    • 解决方案:利用 Pyodide 提供的方案,将浏览器文件转换为 ArrayBuffer,然后写入 Emscripten 文件系统的本地缓存。
  2. 依赖安装问题:PyPI 在中国大陆访问受限。

最终,我们成功实现了一个完全运行在浏览器中的 MarkItDown 工具。欢迎访问 Office File to Markdown 进行体验。

Office File to Markdown

最后放出一下 Worker 中运行 Python 的核心代码:

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() {
  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 })
  }
}