2025-09-07
计算机
00

目录

$\S$ 1 Upmath项目简介
项目功能架构梳理
使用流程梳理
技术优势
$\S$ 2 部署步骤
1. 准备VPS环境+域名
1.1 基础工具
1.2 安装Docker
2. 修改源码(跳过)
3. 两种部署方式:Docker部署或源码部署
(1) Docker部署
A. 从GHCR直接Docker部署(推荐)
B. 本地建造(可选)
(2) 手动安装(只做本机服务,不做域名/HTTPS)
依赖要求
拉代码 + 构建
生成并配置 config.php
配置 nginx(仅本机监听 8080,不绑定域名)
启动 SVGO HTTP 服务(用于 SVG 优化缓存)
本机验证
4. 添加域名解析
5. Nginx反代

§\S 1 Upmath项目简介

Upmath是俄罗斯粒子物理学家Dr. Роман Парпалак主导完成的一个开源前端项目——一个基于Markdown+LaTeX的在线编辑器,见它的项目Github主页。它可以实现在Web上显示复杂的数学公式图形(比如LaTeX Tikz所画的图)的网页内容。

它是将Markdown文本与LaTeX数学表达式转换成HTML页面,并嵌入公式图像(SVG格式)。

项目功能架构梳理

项目组件技术栈与职责
前端 (upmath.me)JavaScript + HTML/CSS 前端编辑器,处理 Markdown + LaTeX 转 HTML,并嵌入公式图像。使用 grunt 构建流程。
后端渲染服务 (i.upmath.me)PHP + TeX Live + nginx + Node.js + Grunt + SVG 工具链:渲染公式为 SVG,并提供 API 服务给前端调用。
图像渲染方式使用 TeX Live 和工具链(如 dvisvgm)将 LaTeX 公式渲染为 SVG 矢量图,支持复杂图形(如 TikZ)。(i.upmath.me)

使用流程梳理

  1. 用户在前端编辑器中输入 Markdown 文本及 LaTeX 公式。

  2. 编辑器将 Markdown 转为 HTML。LaTeX 公式部分则使用 的方式,调用后端渲染服务。

  3. 后端服务渲染 LaTeX 为 SVG 图片,返回给前端展示。

  4. 最终用户在浏览器中看到的是 HTML 页面嵌入的 SVG 数学公式,可复制、分享或发布。

技术优势

  1. 双模块架构:清晰分离编辑与渲染职责,前端专注编辑与呈现,后端专注渲染服务。

  2. 技术栈丰富:前端为 JavaScript + Grunt 构建,后端融合 TeX、PHP、SVG 渲染工具,也支持 Docker 部署,便于复现环境。

  3. 灵活应用:可嵌入到博客、论坛等任何支持 HTML 的平台,只需贴入一段脚本即可动态渲染数学内容。

§\S 2 部署步骤

1. 准备VPS环境+域名

在VPS购买的网站购买一个按需要求的VPS,并且获得了公网IPv4:<你的IP地址>,我们这里使用的Debian 12发行版。 需要

  • 基础工具:
    • git:拉取仓库/更新代码
    • curl:下载脚本、请求接口、健康检查
    • ca-certificates:HTTPS 证书链(否则 curl/git 可能报 TLS 错)
    • gnupg:导入/校验软件源 GPG key(装 Docker、NodeSource 等常用)
    • lsb-release:获取发行版代号(添加 apt 源常用)
    • unzip:解压一些发布包(不少工具会用到)
  • nginx
  • docker

1.1 基础工具

在Debian上我们运行

Terminal
sudo apt update sudo apt install -y nginx git curl ca-certificates gnupg lsb-release

1.2 安装Docker

添加 Docker 官方 GPG key

Terminal
sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg \ | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

添加 Docker 官方 apt 源

Terminal
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \ $(lsb_release -cs) stable" \ | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

安装 Docker 引擎

Terminal
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io

测试

Terminal
sudo docker run hello-world

这样我们就准备好了基本的VPS环境,为了实现后面的域名访问,可以在一些域名购买网站上购买一个域名:<你的域名>。

2. 修改源码(跳过)

由于我已经Fork一个修改好的项目,可以跳过!!!

Fork项目在:

https://github.com/X2M7/i.upmath.me


我们之所以选择源码部署,而不直接选择官方Docker部署的原因是因为我们需要在编辑器有使用中文的需求以及对暗色模式的需求,需要对源代码进行一定改动。下面是在原来代码基础上的修改内容,仅作历史保存:

使用中文方案

首先,我们从Github下载源码

Terminal
git clone https://github.com/parpalak/upmath.me.git

并进入源码所在的文件夹

Terminal
cd i.upmath.me

进入i.upmath.me项目源码文件夹下的tpl文件夹

Terminal
cd tpl

tpl文件夹里面有三个文件,可以通过ls查看

Terminal
ls

我们需要更改document.php这个文件,选择你喜欢的文本编辑器来编辑document.php

PHP
<?php /** @var bool $hasDvisvgmOption */ /** @var string $documentContent */ /** @var \S2\Tex\Tpl\PackageCollection $extraPackages */ /** * \documentclass[11pt,dvisvgm]{standalone} * %\usepackage[paperwidth=180in,paperheight=180in]{geometry} * \usepackage[paperwidth=180in, paperheight=180in,margin=0in]{geometry} * %\usepackage[a4paper, total={6in, 8in}]{geometry} * \standaloneconfig{crop=false} */ ?> \documentclass[11pt<?php if ($hasDvisvgmOption) { ?>,dvisvgm<?php } ?>]{article} \usepackage[paperwidth=180in,paperheight=180in]{geometry} \batchmode % 注意我们添加了这两行 \usepackage[utf8]{inputenc} \usepackage{CJKutf8} \usepackage{amsmath} \usepackage{amssymb} \usepackage{stmaryrd} \newcommand{\R}{\mathbb{R}} \newcommand{\lt}{<} \newcommand{\gt}{>} \usepackage{esint} % 新加对oiint的支持 % Conditional definitions \providecommand{\tg}{\operatorname{tg}} \providecommand{\ctg}{\operatorname{ctg}} \providecommand{\arctg}{\operatorname{arctg}} \providecommand{\arcctg}{\operatorname{arcctg}} \usepackage[verbose]{newunicodechar} \newunicodechar{¬}{\ensuremath{\neg}} \newunicodechar{Γ}{\ensuremath{\Gamma}} \newunicodechar{γ}{\ensuremath{\gamma}} \newunicodechar{λ}{\ensuremath{\lambda}} \newunicodechar{φ}{\ensuremath{\varphi}} \newunicodechar{ψ}{\ensuremath{\psi}} \newunicodechar{ϕ}{\ensuremath{\varphi}} \newunicodechar{ᵢ}{\ensuremath{{}_{i}}} \newunicodechar{₀}{\ensuremath{{}_{0}}} \newunicodechar{₁}{\ensuremath{{}_{1}}} \newunicodechar{₂}{\ensuremath{{}_{2}}} \newunicodechar{₃}{\ensuremath{{}_{3}}} \newunicodechar{₄}{\ensuremath{{}_{4}}} \newunicodechar{₅}{\ensuremath{{}_{5}}} \newunicodechar{₆}{\ensuremath{{}_{6}}} \newunicodechar{₇}{\ensuremath{{}_{7}}} \newunicodechar{₈}{\ensuremath{{}_{8}}} \newunicodechar{₉}{\ensuremath{{}_{9}}} \newunicodechar{ₙ}{\ensuremath{{}_{n}}} \newunicodechar{ℓ}{\ensuremath{\ell}} \newunicodechar{→}{\ensuremath{\rightarrow}} \newunicodechar{⇒}{\ensuremath{\supset}} \newunicodechar{⇔}{\ensuremath{\Leftrightarrow}} \newunicodechar{∅}{\ensuremath{\emptyset}} \newunicodechar{∈}{\ensuremath{\in}} \newunicodechar{∘}{\ensuremath{\circ}} \newunicodechar{∙}{\ensuremath{\bullet}} \newunicodechar{∧}{\ensuremath{\wedge}} \newunicodechar{∨}{\ensuremath{\vee}} \newunicodechar{∼}{\ensuremath{\sim}} \newunicodechar{≠}{\ensuremath{\neq}} \newunicodechar{≡}{\ensuremath{\equiv}} \newunicodechar{⊃}{\ensuremath{\supset}} \newunicodechar{⊕}{\ensuremath{\oplus}} \newunicodechar{⊖}{\ensuremath{\ominus}} \newunicodechar{⊢}{\ensuremath{\vdash}} \newunicodechar{⊤}{\ensuremath{\top}} \newunicodechar{⊥}{\ensuremath{\bot}} \newunicodechar{⊻}{\ensuremath{\veebar}} \newunicodechar{⟝}{\ensuremath{\vdash}} \newunicodechar{⬓}{\ensuremath{\square}} \newunicodechar{Σ}{\ensuremath{\sum}} \newunicodechar{Π}{\ensuremath{\prod}} \newunicodechar{ⱼ}{\ensuremath{{}_{j}}} <?php echo $extraPackages->getCode(); ?> \pagestyle{empty} \setlength{\topskip}{0pt} \setlength{\parindent}{0pt} \setlength{\abovedisplayskip}{0pt} \setlength{\belowdisplayskip}{0pt} \begin{document} \begin{CJK}{UTF8}{gbsn} % 注意我们添加了这一行 <?php foreach (['newwrite', 'openout'] as $disabledCommand) { echo '\\renewcommand{\\' . $disabledCommand . '}{\\errmessage{Command \\noexpand\\' . $disabledCommand . ' is disabled}}', "\n"; } ?> <?php echo $documentContent; ?> \end{CJK}% 注意我们添加了这一行 \end{document}

请注意在原来代码中,我们添加了4行,为了尽量减少修改,我们仍然使用pdfLaTeX编译,因此,我们增加了对CJK包的支持

... \usepackage[utf8]{inputenc} \usepackage{CJKutf8} ...

注:如果你需要更多LaTeX的包的支持,就可以在这里的后面添加\usepackage{<你需要的LaTeX包>}

并在\begin{document}和\end{document}之内,添加了CJK的使用,UTF8是文本格式,而gbsn是宋体(你可以按需更改)

... \begin{document} \begin{CJK}{UTF8}{gbsn} % 注意我们添加了这一行 ... \end{CJK}% 注意我们添加了这一行 \end{document}

保存并退出,这样我们就完成了这个包的修改。回到上一级i.upmath.me/

Terminal
cd ..

为了保证有中文字体的支持,我们在生成Docker容器前写一个Dockerfile,使用你喜欢的编辑器

Terminal
nano Dockerfile

并在其中输入

Dockerfile
FROM ghcr.io/parpalak/upmath-texlive-docker:2025.0.1 EXPOSE 80 WORKDIR /var/www/i.upmath.me RUN apt-get update && apt-get install -y --no-install-recommends \ fonts-noto-cjk \ latex-cjk-all \ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get -y --no-install-recommends install \ nginx-extras lua-zlib \ zip unzip \ php8.2-fpm \ php8.2-curl \ php8.2-xml \ php8.2-gd \ composer \ librsvg2-bin \ optipng \ supervisor \ curl gnupg && \ mkdir -p /etc/apt/keyrings && \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ apt-get update && \ apt-get install -y nodejs && \ apt-get remove -y curl gnupg && \ apt-get autoremove -y && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ rm -rf /var/cache/apt/ COPY . . RUN mkdir -p logs RUN composer install --no-dev RUN npm install -g yarn grunt-cli && \ yarn install && \ grunt && \ yarn install --prod && \ npm uninstall -g yarn grunt-cli RUN mkdir -p /var/run/php-fpm/ RUN cp config.php.dist config.php \ && tlversion=$(cat /usr/local/texlive/20*/release-texlive.txt | head -n 1 | awk '{ print $5 }') \ && sed -i "s/\${tlversion}/${tlversion}/g" config.php RUN cp docker/nginx.conf /etc/nginx/nginx.conf RUN cp docker/www.conf /etc/php/8.2/fpm/pool.d/www.conf && \ cp docker/www-tex.conf /etc/php/8.2/fpm/pool.d/www-tex.conf RUN cp docker/superv.conf /etc/superv.conf ENTRYPOINT [ "/var/www/i.upmath.me/docker/entrypoint.sh" ]

保存并退出即可。

暗色模式附加方案

为了实现“默认不变+?c=暗色改色+不串缓存”,你一共需要改动这些文件:

(1) 颜色改写(只在带?c=时生效)

(a) lib/Renderer/SvgHelper.php

php
<?php namespace S2\Tex\Renderer; class SvgHelper { private const POINTS_IN_PIXEL = 0.75; private const TOP_SHIFT_RATIO = 0.5; private static function normalizeHexColor(?string $c): ?string { if ($c === null) return null; $c = strtolower(trim($c)); $c = ltrim($c, '#'); if (preg_match('/^[0-9a-f]{3}$/', $c)) { return '#' . $c[0].$c[0] . $c[1].$c[1] . $c[2].$c[2]; } if (preg_match('/^[0-9a-f]{6}$/', $c)) { return '#' . $c; } return null; } /** * Only apply recolor when ?c=xxxxxx (or ?color=xxxxxx) exists. * - Root fill="currentColor" * - Root style="color:#xxxxxx" * - Replace explicit pure-black representations to currentColor * - Do NOT set root stroke (avoid bold glyphs) */ private static function applyColorParam(string $svg): string { $color = self::normalizeHexColor($_GET['c'] ?? ($_GET['color'] ?? null)); if ($color === null) { return $svg; // default output unchanged } // 1) Root svg: fill=currentColor + color=<hex> $svg = preg_replace_callback('/<svg\b([^>]*)>/', static function (array $m) use ($color): string { $attrs = $m[1]; // Ensure default fill follows currentColor if (stripos($attrs, ' fill=') === false) { $attrs .= ' fill="currentColor"'; } // Update or append style="color:..." if (preg_match('/\sstyle=("|\')([^"\']*)\1/i', $attrs, $sm)) { $quote = $sm[1]; $style = $sm[2]; // remove existing color:... $style = preg_replace('/(^|;)\s*color\s*:\s*[^;]+/i', '$1', $style); $style = trim($style); if ($style !== '' && substr($style, -1) !== ';') $style .= ';'; $style .= 'color:' . $color . ';'; $attrs = preg_replace('/\sstyle=("|\')([^"\']*)\1/i', ' style=' . $quote . $style . $quote, $attrs, 1); } else { $attrs .= ' style="color:' . $color . ';"'; } return '<svg' . $attrs . '>'; }, $svg, 1); // 2) Replace pure black in attributes -> currentColor // Support BOTH single and double quotes. $svg = preg_replace( '/\b(fill|stroke|stop-color)\s*=\s*(["\'])(#000000|#000|black)\2/i', '$1=$2currentColor$2', $svg ); // rgb(0,0,0) or rgb(0%,0%,0%) or rgb(0.0%,0.0%,0.0%) $svg = preg_replace( '/\b(fill|stroke|stop-color)\s*=\s*(["\'])rgb\(\s*0(?:\.0+)?%?\s*,\s*0(?:\.0+)?%?\s*,\s*0(?:\.0+)?%?\s*\)\2/i', '$1=$2currentColor$2', $svg ); // rgb(0 0 0) space-separated variant $svg = preg_replace( '/\b(fill|stroke|stop-color)\s*=\s*(["\'])rgb\(\s*0(?:\.0+)?%?\s+0(?:\.0+)?%?\s+0(?:\.0+)?%?\s*\)\2/i', '$1=$2currentColor$2', $svg ); // rgba(0,0,0,1) $svg = preg_replace( '/\b(fill|stroke|stop-color)\s*=\s*(["\'])rgba\(\s*0(?:\.0+)?\s*,\s*0(?:\.0+)?\s*,\s*0(?:\.0+)?\s*,\s*1(?:\.0+)?\s*\)\2/i', '$1=$2currentColor$2', $svg ); // 3) Replace pure black in inline style -> currentColor $svg = preg_replace('/\b(fill|stroke|stop-color)\s*:\s*#000000\b/i', '$1:currentColor', $svg); $svg = preg_replace('/\b(fill|stroke|stop-color)\s*:\s*#000\b/i', '$1:currentColor', $svg); $svg = preg_replace('/\b(fill|stroke|stop-color)\s*:\s*black\b/i', '$1:currentColor', $svg); $svg = preg_replace('/\b(fill|stroke|stop-color)\s*:\s*rgb\(\s*0(?:\.0+)?%?\s*,\s*0(?:\.0+)?%?\s*,\s*0(?:\.0+)?%?\s*\)\b/i', '$1:currentColor', $svg); $svg = preg_replace('/\b(fill|stroke|stop-color)\s*:\s*rgb\(\s*0(?:\.0+)?%?\s+0(?:\.0+)?%?\s+0(?:\.0+)?%?\s*\)\b/i', '$1:currentColor', $svg); $svg = preg_replace('/\b(fill|stroke|stop-color)\s*:\s*rgba\(\s*0(?:\.0+)?\s*,\s*0(?:\.0+)?\s*,\s*0(?:\.0+)?\s*,\s*1(?:\.0+)?\s*\)\b/i', '$1:currentColor', $svg); return $svg; } public static function processSvgContent(string $svg, bool $useBaseline): string { $startPattern = '#<!--start (-?[\d.]+) (-?[\d.]+) -->#'; if (!preg_match($startPattern, $svg, $matchBaseline)) { return self::applyColorParam($svg); } $viewBoxPattern = '#viewBox=["\'](-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)["\']#'; if (!preg_match($viewBoxPattern, $svg, $matchViewBox)) { return self::applyColorParam($svg); } [, $userStartX, $userStartY, $userWidth, $userHeight] = $matchViewBox; if ($userWidth < 0.000001 || $userHeight < 0.000001) { return self::applyColorParam($svg); } $userBaselineY = $matchBaseline[2]; $userFromTopToBaseline = max(0, $userBaselineY - $userStartY); $userFromBottomToBaseline = $useBaseline ? max($userHeight - $userFromTopToBaseline, 0) : $userHeight * 0.5; $multiplier = OUTER_SCALE / self::POINTS_IN_PIXEL; $viewportFromBottomToBaseline = $multiplier * $userFromBottomToBaseline; $viewportHeight = $multiplier * $userHeight; $viewportWidth = $multiplier * $userWidth; $extendedViewportHeight = ceil($viewportHeight); $extendedViewportWidth = ceil($viewportWidth); $extendedViewportFromBottomToBaseline = $viewportFromBottomToBaseline + (1 - self::TOP_SHIFT_RATIO) * ($extendedViewportHeight - $viewportHeight); $extendedUserHeight = $userHeight * $extendedViewportHeight / $viewportHeight; $extendedUserWidth = $userWidth * $extendedViewportWidth / $viewportWidth; $svg = preg_replace( '#<svg\b[^>]*>#', sprintf( '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%s" height="%s" viewBox="%s %s %s %s">', round($extendedViewportWidth, 6), round($extendedViewportHeight, 6), $userStartX, round($userStartY - self::TOP_SHIFT_RATIO * ($extendedUserHeight - $userHeight), 6), round($extendedUserWidth, 6), round($extendedUserHeight, 6) ), $svg, 1 ); $script = sprintf( '<script type="text/ecmascript">if(window.parent.postMessage)window.parent.postMessage("%s|%s|%s|"+window.location,"*");</script>', round($extendedViewportFromBottomToBaseline * self::POINTS_IN_PIXEL, 5), round($extendedViewportWidth * self::POINTS_IN_PIXEL, 5), round($extendedViewportHeight * self::POINTS_IN_PIXEL, 5) ); $svg = str_replace('</svg>', $script . "\n" . '</svg>', $svg); return self::applyColorParam($svg); } }

(b) lib/Cache/CacheProvider.php

php
<?php /** * @copyright 2020-2022 Roman Parpalak * @license http://www.opensource.org/licenses/mit-license.php MIT * @package Upmath Latex Renderer * @link https://i.upmath.me */ namespace S2\Tex\Cache; use S2\Tex\Processor\Request; class CacheProvider { protected string $cacheFailDir; protected string $cacheSuccessDir; public function __construct(string $cacheSuccessDir, string $cacheFailDir) { $this->cacheFailDir = $cacheFailDir; $this->cacheSuccessDir = $cacheSuccessDir; } public function getCacheState(Request $request): CacheState { $cacheName = $this->cachePathFromRequest($request, false); return new CacheState($cacheName, file_exists($cacheName)); } /** * Returns the cached path. * This algorithm should be used by a web-server to process the cache files as a static content. */ public function cachePathFromRequest(Request $request, bool $hasError): string { // IMPORTANT: // old code: md5(formula) -> will mix /svg/x and /svg/x?c=... // new code: include extension + variant, and bump schema with "cache-v2" $hash = md5( $request->getFormula() . "\n" . $request->getExtension() . "\n" . $request->getVariant() . "\n" . 'cache-v2' ); $prefixDir = $hasError ? $this->cacheFailDir : $this->cacheSuccessDir; return $prefixDir . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . substr($hash, 4) . '.' . $request->getExtension(); } }

(2) 让Request能拿到query(否则 variant 永远为空),修改www/render.php如下:

php
<?php /** * Entry point for rendering. * * @copyright 2014-2020 Roman Parpalak * @license http://www.opensource.org/licenses/mit-license.php MIT * @package Upmath Latex Renderer * @link https://i.upmath.me */ use hollodotme\FastCGI\Client; use hollodotme\FastCGI\Requests\PostRequest; use hollodotme\FastCGI\SocketConnections\UnixDomainSocket; use Katzgrau\KLogger\Logger; use S2\Tex\Cache\CacheProvider; use S2\Tex\Processor\CachedResponse; use S2\Tex\Processor\PostProcessor; use S2\Tex\Processor\Processor; use S2\Tex\Processor\Request; use S2\Tex\Renderer\PngConverter; use S2\Tex\Renderer\Renderer; use S2\Tex\Templater; require '../vendor/autoload.php'; require '../config.php'; $isDebug = defined('DEBUG') && DEBUG; error_reporting($isDebug ? E_ALL : -1); // Setting up external commands define('LATEX_COMMAND', TEX_PATH . 'latex -output-directory=' . TMP_DIR); define('DVISVG_COMMAND', TEX_PATH . 'dvisvgm %1$s -o %1$s.svg -n --exact -v0 --relative --zoom=' . OUTER_SCALE); // define('DVIPNG_COMMAND', TEX_PATH . 'dvipng -T tight %1$s -o %1$s.png -D ' . (96 * OUTER_SCALE)); // outdated define('SVG2PNG_COMMAND', 'rsvg-convert %1$s -d 96 -p 96 -b white'); // stdout function error400($error = 'Invalid formula') { header($_SERVER['SERVER_PROTOCOL'] . ' 400 Bad Request'); include '400.php'; } //ignore_user_abort(); ini_set('max_execution_time', 10); header('X-Powered-By: Upmath Latex Renderer'); $templater = new Templater(TPL_DIR); $pngConverter = new PngConverter(SVG2PNG_COMMAND); $renderer = new Renderer($templater, TMP_DIR, TEX_PATH, LATEX_COMMAND, DVISVG_COMMAND); $renderer ->setPngConverter($pngConverter) ->setIsDebug($isDebug); if (defined('LOG_DIR')) { $renderer->setLogger(new Logger(LOG_DIR)); } $cacheProvider = new CacheProvider(CACHE_SUCCESS_DIR, CACHE_FAIL_DIR); $processor = new Processor($renderer, $cacheProvider, $pngConverter); try { // IMPORTANT: use full REQUEST_URI so Request can see query (?c=...) $request = Request::createFromUri($_SERVER['REQUEST_URI']); } catch (Exception $e) { error400($isDebug ? $e->getMessage() : 'Invalid formula'); die; } $response = $processor->process($request); if (!$response->hasError()) { $response->echoContent(); } else { error400($isDebug ? $response->getError() : 'Invalid formula'); } if (!$isDebug && !($response instanceof CachedResponse)) { // Disconnecting from web-server flush(); fastcgi_finish_request(); $postProc = new PostProcessor($cacheProvider); $asyncRequest = $postProc->cacheResponseAndGetAsyncRequest($response, $_SERVER['HTTP_REFERER'] ?? 'no referer'); if ($asyncRequest !== null) { $connection = new UnixDomainSocket(FASTCGI_SOCKET, 1000, 1000); $client = new Client(); // IMPORTANT: pass color param to async processor too $content = http_build_query([ 'formula' => $asyncRequest->getFormula(), 'extension' => $asyncRequest->getExtension(), 'c' => $_GET['c'] ?? ($_GET['color'] ?? ''), ]); $request = new PostRequest(realpath('../cache_processor.php'), $content); $client->sendAsyncRequest($connection, $request); } }

(3) 异步缓存处理也必须带上 variant(否则异步优化会写错缓存),修改cache_processor.php如下:

php
<?php /** * Entry point for async cache optimizer. * * @copyright 2020-2022 Roman Parpalak * @license http://www.opensource.org/licenses/mit-license.php MIT * @package Upmath Latex Renderer * @link https://i.upmath.me */ require 'vendor/autoload.php'; require 'config.php'; // Fallback commands, now HTTP service is used. define('SVGO', realpath(SVGO_PATH) . '/svgo -i %1$s -o %1$s.new; rm %1$s; mv %1$s.new %1$s'); define('GZIP', 'gzip -cn6 %1$s > %1$s.gz.new; rm %1$s.gz; mv %1$s.gz.new %1$s.gz; rm %1$s'); // outdated, disabled due to SVG is now well-supported in browsers. define('OPTIPNG', 'optipng %1$s'); define('PNGOUT', 'pngout %1$s'); use S2\Tex\Cache\CacheProvider; use S2\Tex\Processor\DelayedProcessor; use S2\Tex\Processor\Request; $delayedProcessor = new DelayedProcessor( new CacheProvider(CACHE_SUCCESS_DIR, CACHE_FAIL_DIR), 'http://localhost:' . (defined('HTTP_SVGO_PORT') ? HTTP_SVGO_PORT : '8800') . '/' ); $delayedProcessor ->addSVGCommand(SVGO) ->addSVGCommand(GZIP) // ->addPNGCommand(OPTIPNG) // ->addPNGCommand(PNGOUT) ; $formula = $_POST['formula'] ?? ''; $ext = $_POST['extension'] ?? 'svg'; // IMPORTANT: same variant logic as render.php/request parsing $variant = Request::buildVariantFromParams($_POST); $request = new Request($formula, $ext, $variant); $delayedProcessor->process($request);

3. 两种部署方式:Docker部署或源码部署

(1) Docker部署

A. 从GHCR直接Docker部署(推荐)

在服务器终端运行

bash
docker run -d --name upmath -p 8080:80 ghcr.io/x2m7/i.upmath.me:latest

B. 本地建造(可选)

bash
git clone https://github.com/X2M7/i.upmath.me.git cd i.upmath.me docker build -t i-upmath-me:local . docker run --rm -p 8080:80 i-upmath-me:local

然后打开确定容器顺利运行:


(2) 手动安装(只做本机服务,不做域名/HTTPS)

目标:部署完成后,你能在服务器本机访问
http://127.0.0.1:8080

依赖要求

  • php-fpm(且 proc_open() 可用)
  • Node.js + yarn + grunt-cli(用于前端构建)
  • TeX Live(建议 full)
  • ghostscript、dvisvgm(公式/图渲染链路)
  • nginx(带 Lua 模块)
  • librsvg2-bin 用于 SVG → PNG(你若只用 SVG 可不装)
bash
sudo apt update sudo apt install -y \ php-fpm php-curl php-xml php-gd \ nodejs npm yarn \ texlive-full ghostscript dvisvgm \ librsvg2-bin \ nginx-extras

拉代码 + 构建

bash
git clone https://github.com/X2M7/i.upmath.me.git cd i.upmath.me # PHP 依赖 composer install --no-dev # 前端构建 yarn install npx grunt yarn install --prod

生成并配置 config.php

bash
cp config.php.dist config.php nano config.php

你通常需要确认/修改这些:

  • TEX_PATH:TeX 可执行文件路径(比如 /usr/bin/ 或 TeXLive 的 bin 目录)
  • TMP_DIRCACHE_SUCCESS_DIRCACHE_FAIL_DIR:确保 nginx/php-fpm 有读写权限

(权限建议做法:把 cache/tmp 目录 owner 给运行 php-fpm 的用户/组,或给可写权限)

配置 nginx(仅本机监听 8080,不绑定域名)

这里建议你单独建一个“内部站点”配置,例如:

bash
sudo cp nginx.conf.dist /etc/nginx/sites-available/i.upmath.internal sudo nano /etc/nginx/sites-available/i.upmath.internal

把 server 段改成类似这种“内部服务”形态(核心点:listen 127.0.0.1:8080;):

nginx
server { listen 127.0.0.1:8080; server_name _; root /path/to/i.upmath.me/www; index index.php; # 其余 location/php/lua/静态缓存规则 # 建议直接沿用项目自带 nginx.conf.dist 的内容, # 这里只改 listen/root 等与路径相关的部分即可。 include /etc/nginx/snippets/fastcgi-php.conf; }

启用并重载:

bash
sudo ln -s /etc/nginx/sites-available/i.upmath.internal /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx

注意:上面 root 请换成你实际的项目路径,比如 /var/www/i.upmath.me/www

启动 SVGO HTTP 服务(用于 SVG 优化缓存)

bash
sudo cp http-svgo.service.dist /etc/systemd/system/http-svgo.service sudo sed -i "s~@@DIR@@~$PWD~g" /etc/systemd/system/http-svgo.service sudo systemctl daemon-reload sudo systemctl enable --now http-svgo

本机验证

在服务器上执行:

bash
curl -I http://127.0.0.1:8080

能返回 200/302 等正常响应即代表“内部服务已起来”。

4. 添加域名解析

打开购买域名的网站,在<你的域名>中添加两条域名解析

主机记录 记录类型 线路类型 记录值 TTL
www A 默认 <你的IP地址> 600
@ A 默认 <你的IP地址> 600

5. Nginx反代

编辑/etc/nginx/sites-available/下的default文件

Terminal
nano /etc/nginx/sites-available/default

在default最后添加

server { server_name <你的域名> www.<你的域名>; # 所有请求反代到内部服务 <你的IP地址>:8080 location / { proxy_pass http://<你的IP地址>:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/<你的域名>/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/<你的域名>/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot } server { if ($host = www.<你的域名>) { return 301 https://$host$request_uri; } # managed by Certbot if ($host = <你的域名>) { return 301 https://$host$request_uri; } # managed by Certbot listen 80; server_name <你的域名> www.<你的域名>; return 404; # managed by Certbot }

注意替换所有的<你的IP地址>和<你的域名>为你所使用VPS的IPv4地址和你的域名。

测试Nginx配置

Terminal
sudo nginx -t

确保没有报错,那么重启Nginx

Terminal
sudo systemctl reload nginx

为了开启HTTPS,我们需要安装Certbot

Terminal
sudo apt install certbot python3-certbot-nginx -y

并且申请证书并自动修改Nginx

Terminal
sudo certbot --nginx -d <你的域名> -d www.<你的域名>

完成后, Nginx会自动生成443端口的配置,HTTP自动跳转到HTTPS。

这样你就可以通过<你的域名>访问搭建好的Upmath服务了!它还支持中文!大功告成!

当然也欢迎使用我目前搭建好的Upmath服务:https://xumin.net