2024-05-28
Computer Science
00

Contents

$\S$ 1 Introduction to the Upmath Project
Project Architecture
Workflow
Technical Advantages
$\S$ 2 Deployment Steps
1. Prepare the VPS Environment + Domain Name
1.1 Required Tools
1.2 Install Basic Tools
1.3 Install Docker
1.3 Prepare a Domain Name
2. Modify the Source Code (Skip)
3. Two Deployment Methods: Docker or Source Deployment
A. Deploy Directly from GHCR with Docker (Recommended)
B. Manual Installation
(1) Dependencies
(2) Install Dependencies
(3) Clone the Source Code
(4) Install Dependencies + Build the Frontend
(4) Generate and Configure config.php
(5) Configure Nginx to Listen Only on Local Port 8080
(a) Change listen to a localhost-only port, for example:
(b) Point the site root to the project's www directory, for example:
(6) Start the SVGO HTTP Service for SVG Cache Optimization
(7) Verify Locally
4. Add DNS Records
5. Nginx Reverse Proxy

§\S 1 Introduction to the Upmath Project

Upmath is an open-source frontend project led by Russian particle physicist Dr. Roman Parpalak. It is an online editor based on Markdown + LaTeX. See its GitHub repository.

It can display complex mathematical formulas and graphics on the Web, including figures drawn with LaTeX TikZ.

It converts Markdown text and LaTeX mathematical expressions into HTML pages and embeds rendered formula images in SVG format.

Project Architecture

ComponentTechnology Stack and Responsibilities
Frontend (upmath.me)JavaScript + HTML/CSS frontend editor. Converts Markdown + LaTeX into HTML and embeds formula images. Uses grunt as its build system.
Backend rendering service (i.upmath.me)PHP + TeX Live + nginx + Node.js + Grunt + SVG toolchain. Renders formulas into SVG and provides an API for the frontend.
Image renderingUses TeX Live and tools such as dvisvgm to render LaTeX formulas into SVG vector graphics, with support for complex graphics such as TikZ. (i.upmath.me)

Workflow

  1. The user enters Markdown text and LaTeX formulas in the frontend editor.

  2. The editor converts Markdown into HTML. For LaTeX formulas, it uses

html
<script src="//i.upmath.me/latex.js"></script>

to call the backend rendering service.

  1. The backend service renders the LaTeX into SVG images and returns them to the frontend.

  2. The user ultimately sees an HTML page containing embedded SVG mathematical formulas, which can be copied, shared, or published.

Technical Advantages

  1. Dual-module architecture: editing and rendering responsibilities are clearly separated. The frontend focuses on editing and presentation, while the backend focuses on rendering.

  2. Rich technology stack: the frontend uses JavaScript + Grunt, while the backend combines TeX, PHP, SVG rendering tools, and Docker support, making the environment easy to reproduce.

  3. Flexible integration: it can be embedded into blogs, forums, or any platform that supports HTML. A small script is enough to dynamically render mathematical content.

§\S 2 Deployment Steps

1. Prepare the VPS Environment + Domain Name

Purchase a VPS that meets your requirements from a VPS provider and obtain a public IPv4 address:

text
<your IP address>

In this guide, we use the Debian 12 distribution.

1.1 Required Tools

  • git
  • curl
  • ca-certificates
  • gnupg
  • lsb-release
  • Docker

1.2 Install Basic Tools

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

1.3 Install Docker

Add Docker's official GPG key:

bash
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

Add the official Docker apt repository:

bash
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

Install the Docker engine:

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

Test it:

bash
sudo docker run hello-world

Now the basic VPS environment is ready.

1.3 Prepare a Domain Name

To access the service through a domain later, purchase a domain name from a domain registrar:

text
<your domain>

2. Modify the Source Code (Skip)

Since I have already forked and modified the project, you can skip this section.

The forked project is available at:

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

The following modifications to the original source code are kept here only for historical reference.

Chinese-Language Support

The reason we choose to deploy from source rather than directly use the official Docker deployment is that we need Chinese-language support in the editor, which requires some modifications to the source code.

First, clone the source code from GitHub:

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

Enter the source-code directory:

Terminal
cd i.upmath.me

Enter the tpl directory inside the i.upmath.me project:

Terminal
cd tpl

There are three files inside the tpl directory. You can view them with:

Terminal
ls

We need to modify document.php.

Open document.php with your preferred text editor:

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 % Note that we added these two lines \usepackage[utf8]{inputenc} \usepackage{CJKutf8} \usepackage{amsmath} \usepackage{amssymb} \usepackage{stmaryrd} \newcommand{\R}{\mathbb{R}} \newcommand{\lt}{<} \newcommand{\gt}{>} % 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} % Note that we added this line <?php foreach (['newwrite', 'openout'] as $disabledCommand) { echo '\\renewcommand{\\' . $disabledCommand . '}{\\errmessage{Command \\noexpand\\' . $disabledCommand . ' is disabled}}', "\n"; } ?> <?php echo $documentContent; ?> \end{CJK}% Note that we added this line \end{document}

Notice that we added four lines to the original code.

To minimize the changes, we continue using pdfLaTeX for compilation, so we added support for the CJK package:

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

Note: If you need support for additional LaTeX packages, you can add

latex
\usepackage{<the LaTeX package you need>}

after this section.

We also added the CJK environment inside \begin{document} and \end{document}.

Here, UTF8 specifies the text encoding, while gbsn is a Song-style Chinese font. You can replace it as needed.

latex
... \begin{document} \begin{CJK}{UTF8}{gbsn} % Note that we added this line ... \end{CJK}% Note that we added this line \end{document}

Save and exit.

This completes the package modification.

Return to the parent i.upmath.me/ directory:

Terminal
cd ..

To ensure Chinese font support, create a Dockerfile before building the Docker container.

Use your preferred editor:

Terminal
nano Dockerfile

Enter the following:

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" ]

Save and exit.

Additional Dark Mode Solution

To achieve the following behavior:

  • default output remains unchanged;
  • ?c= applies a color for dark mode;
  • different color variants do not share the same cache;

you need to modify the following files.

(1) Color rewriting, applied only when ?c= is present.

(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) Allow Request to receive the query parameters; otherwise variant will always be empty.

Modify www/render.php as follows:

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) The asynchronous cache processor must also receive the variant; otherwise the asynchronous optimization process will write to the wrong cache.

Modify cache_processor.php as follows:

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'); // 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. Two Deployment Methods: Docker or Source Deployment

A. Deploy Directly from GHCR with Docker (Recommended)

Run the following command on the server:

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

Verify locally:

bash
curl -I http://127.0.0.1:8080

If you receive a normal response such as 200 or 302, the internal service is running successfully.

B. Manual Installation

(1) Dependencies

  • php-fpm
  • Node.js + yarn + grunt-cli
  • TeX Live, preferably the full installation, + ghostscript + dvisvgm
  • librsvg2-bin
  • nginx with the Lua module

(2) Install Dependencies

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

(3) Clone the Source Code

bash
git clone https://github.com/X2M7/i.upmath.me.git cd i.upmath.me

(4) Install Dependencies + Build the Frontend

bash
# PHP dependencies composer install --no-dev # Frontend dependencies + build yarn install npx grunt yarn install --prod

(4) Generate and Configure config.php

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

Check or modify the following settings carefully:

  • TEX_PATH: the directory containing the TeX executables, for example /usr/bin/ or TeX Live's .../bin/x86_64-linux/.
  • TMP_DIR, CACHE_SUCCESS_DIR, and CACHE_FAIL_DIR: these directories must exist and must be readable and writable by nginx/php-fpm.

It is recommended to create the directories and configure their permissions.

For example, if the project is located at /var/www/i.upmath.me:

bash
sudo mkdir -p cache tmp sudo chown -R www-data:www-data cache tmp

(5) Configure Nginx to Listen Only on Local Port 8080

Create an internal-site configuration file:

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

Make the following two necessary modifications according to your actual environment.

(a) Change listen to a localhost-only port, for example:
nginx
listen 127.0.0.1:8080;
(b) Point the site root to the project's www directory, for example:
nginx
root /path/to/i.upmath.me/www;

Enable the site and reload Nginx:

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

(6) Start the SVGO HTTP Service for SVG Cache Optimization

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

(7) Verify Locally

bash
curl -I http://127.0.0.1:8080

If you receive a normal response such as 200 or 302, the internal service is running successfully.

4. Add DNS Records

Open the website of your domain registrar and add two DNS records for <your domain>:

Host Record Record Type Routing Type Record Value TTL
www A Default <your IP address> 600
@ A Default <your IP address> 600

5. Nginx Reverse Proxy

Edit the default file under /etc/nginx/sites-available/:

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

Add the following to the end of the file:

nginx
server { server_name <your domain> www.<your domain>; # Reverse proxy all requests to the internal service at <your IP address>:8080 location / { proxy_pass http://<your IP address>: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/<your domain>/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/<your domain>/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.<your domain>) { return 301 https://$host$request_uri; } # managed by Certbot if ($host = <your domain>) { return 301 https://$host$request_uri; } # managed by Certbot listen 80; server_name <your domain> www.<your domain>; return 404; # managed by Certbot }

Make sure to replace every occurrence of

text
<your IP address>

and

text
<your domain>

with the IPv4 address of your VPS and your actual domain name.

Test the Nginx configuration:

Terminal
sudo nginx -t

Make sure there are no errors, then reload Nginx:

Terminal
sudo systemctl reload nginx

To enable HTTPS, install Certbot:

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

Then request a certificate and let Certbot automatically modify the Nginx configuration:

Terminal
sudo certbot --nginx -d <your domain> -d www.<your domain>

After completion, Nginx will automatically generate the configuration for port 443, and HTTP requests will automatically redirect to HTTPS.

You can now access your deployed Upmath service through:

text
<your domain>

It also supports Chinese.

Done!

You are also welcome to use the Upmath service that I currently host:

https://tex.xumin.net