KaTeX 0.16.11 embarqué et servi sous /katex/ (Go et façade)

Repris d'AJEAN (d6136caa, #123) : script, feuille de style, polices woff2
et licence MIT sous internal/loki/ui/katex/, embarqués par le go:embed et
par build.rs de la façade. Pas de CDN : l'interface doit marcher hors
ligne.

Route /katex/ des deux côtés avec les mêmes en-têtes : katex.min.js
(application/javascript), katex.min.css (text/css), fonts/*.woff2
(font/woff2), Cache-Control public 1 jour ; script et feuille compressés
en gzip, polices non. Contrairement à AJEAN (http.FileServer), le Go
(web_katex.go) ne sert que ces fichiers, avec un Content-Type fixe : ni
index de dossier, ni type décidé par le registre MIME de l'OS. La façade
(ui.rs) rend les mêmes réponses ; le reste de /katex/ part au Go (404).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-19 11:23:16 +02:00
1 parent 6a45f19831
commit 99c96a6c33
28 files changed
+267 -1

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
+59
View File
@@ -0,0 +1,59 @@
package loki
import (
"net/http"
"path"
"strconv"
"strings"
)
// web_katex.go — KaTeX 0.16.11 (formules LaTeX des réponses), repris d'AJEAN.
//
// Embarqué sous ui/katex/ et servi sous /katex/ : l'UI ne le charge qu'à la
// première formule d'une réponse (00-md.js), et il doit marcher hors ligne,
// sans appeler un CDN. Seuls le script, la feuille de style et les polices
// woff2 sont servis — pas d'index de dossier — avec un Content-Type fixe : le
// registre MIME de l'OS (Windows notamment) ne doit pas le décider, et la
// façade Rust (rust/loki-rs/src/ui.rs) rend exactement les mêmes en-têtes.
// katexTypes : extensions servies et leur Content-Type.
var katexTypes = map[string]string{
".js": "application/javascript",
".css": "text/css; charset=utf-8",
".woff2": "font/woff2",
}
// katexFile : chemin relatif à ui/katex/ d'une ressource servable, ou "" si la
// demande n'en est pas une (racine, dossier, autre extension, remontée).
func katexFile(urlPath string) (rel, ctype string) {
rel = strings.TrimPrefix(urlPath, "/katex/")
if rel == urlPath || rel == "" || strings.Contains(rel, "..") || path.Clean(rel) != rel {
return "", ""
}
ctype, ok := katexTypes[path.Ext(rel)]
if !ok {
return "", ""
}
return rel, ctype
}
func handleKatex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
rel, ctype := katexFile(r.URL.Path)
if rel == "" {
http.NotFound(w, r)
return
}
b, err := uiFS.ReadFile("ui/katex/" + rel)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
w.Write(b)
}
+94
View File
@@ -0,0 +1,94 @@
package loki
import (
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
)
func getKatex(t *testing.T, méthode, chemin string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handleKatex(rec, httptest.NewRequest(méthode, chemin, nil))
return rec
}
// Le script, embarqué, est servi sous /katex/ avec les en-têtes que rend aussi
// la façade Rust (ui.rs).
func TestKatexScriptServi(t *testing.T) {
rec := getKatex(t, http.MethodGet, "/katex/katex.min.js")
if rec.Code != http.StatusOK {
t.Fatalf("code %d", rec.Code)
}
h := rec.Header()
if got := h.Get("Content-Type"); got != "application/javascript" {
t.Errorf("Content-Type = %q", got)
}
if got := h.Get("Cache-Control"); got != "public, max-age=86400" {
t.Errorf("Cache-Control = %q", got)
}
if !strings.Contains(rec.Body.String(), `version:"0.16.11"`) {
t.Error("katex.min.js n'est pas KaTeX 0.16.11")
}
if h.Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length = %q pour %d octets", h.Get("Content-Length"), rec.Body.Len())
}
}
// Chaque police woff2 que la feuille de style demande est embarquée et servie.
func TestKatexFeuilleEtPolices(t *testing.T) {
rec := getKatex(t, http.MethodGet, "/katex/katex.min.css")
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" {
t.Fatalf("css : code %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
}
polices := regexp.MustCompile(`url\((fonts/[^)]+\.woff2)\)`).FindAllStringSubmatch(rec.Body.String(), -1)
if len(polices) < 10 {
t.Fatalf("%d polices woff2 dans la feuille", len(polices))
}
for _, m := range polices {
r := getKatex(t, http.MethodGet, "/katex/"+m[1])
if r.Code != http.StatusOK || r.Header().Get("Content-Type") != "font/woff2" || r.Body.Len() == 0 {
t.Errorf("%s : code %d, type %q", m[1], r.Code, r.Header().Get("Content-Type"))
}
}
}
// Rien d'autre : ni index de dossier, ni licence, ni remontée, ni POST.
func TestKatexRefus(t *testing.T) {
for _, c := range []struct{ méthode, chemin string }{
{http.MethodGet, "/katex/"},
{http.MethodGet, "/katex/fonts/"},
{http.MethodGet, "/katex/LICENSE"},
{http.MethodGet, "/katex/fonts/absente.woff2"},
{http.MethodGet, "/katex/../index.html"},
{http.MethodGet, "/katex/fonts/../katex.min.js"},
{http.MethodGet, "/katexkatex.min.js"},
{http.MethodPost, "/katex/katex.min.js"},
} {
if rec := getKatex(t, c.méthode, c.chemin); rec.Code != http.StatusNotFound {
t.Errorf("%s %s : code %d, attendu 404", c.méthode, c.chemin, rec.Code)
}
}
}
// Derrière gzipHandler (comme en production) : script et feuille compressés,
// polices non — comme la façade.
func TestKatexCompression(t *testing.T) {
h := gzipHandler(http.HandlerFunc(handleKatex))
for chemin, gz := range map[string]bool{
"/katex/katex.min.js": true,
"/katex/katex.min.css": true,
"/katex/fonts/KaTeX_Main-Regular.woff2": false,
} {
req := httptest.NewRequest(http.MethodGet, chemin, nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Encoding") == "gzip"; got != gz {
t.Errorf("%s : gzip %v, attendu %v", chemin, got, gz)
}
}
}
+4 -1
View File
@@ -19,7 +19,7 @@ import (
)
//go:generate go run ../../tools/assemble-ui ui
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2 ui/katex
var uiFS embed.FS
// cmdWeb starts the HTTP server on the given port (default 8090).
@@ -217,6 +217,9 @@ func newWebMux() *http.ServeMux {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(b)
})
// KaTeX (formules LaTeX des réponses, web_katex.go) : chargé par l'UI
// seulement quand une réponse contient des maths. Embarqué, comme les polices.
mux.HandleFunc("/katex/", handleKatex)
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
+26
View File
@@ -42,6 +42,32 @@ fn main() {
}
out.push_str("];\n");
// KaTeX (formules LaTeX, repris d'AJEAN) : script, feuille et polices
// woff2 sous ui/katex/, les polices nommées par leur chemin relatif
// (« fonts/x.woff2 »), comme les sert web_katex.go.
let katex = ui.join("katex");
println!("cargo:rerun-if-changed={}", katex.display());
for (ident, name) in [("KATEX_JS", "katex.min.js"), ("KATEX_CSS", "katex.min.css")] {
let p = katex.join(name);
assert!(p.is_file(), "fichier KaTeX manquant : {}", p.display());
println!("cargo:rerun-if-changed={}", p.display());
writeln!(out, "pub static {ident}: &[u8] = include_bytes!({p:?});").unwrap();
}
let katex_fonts = katex.join("fonts");
println!("cargo:rerun-if-changed={}", katex_fonts.display());
let mut kfonts: Vec<PathBuf> = std::fs::read_dir(&katex_fonts)
.unwrap_or_else(|e| panic!("{} : {e}", katex_fonts.display()))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "woff2"))
.collect();
kfonts.sort();
out.push_str("pub static KATEX_FONTS: &[(&str, &[u8])] = &[\n");
for p in &kfonts {
let name = format!("fonts/{}", p.file_name().unwrap().to_str().unwrap());
writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap();
}
out.push_str("];\n");
let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
std::fs::write(dst, out).unwrap();
}
+61
View File
@@ -48,6 +48,10 @@ static MANIFEST: Asset = Asset::new(
"application/manifest+json",
"public, max-age=3600",
);
/// KaTeX (formules LaTeX des réponses) : mêmes en-têtes que `web_katex.go`.
const KATEX_CACHE: &str = "public, max-age=86400";
static KATEX_JS: Asset = Asset::new(assets::KATEX_JS, "application/javascript", KATEX_CACHE);
static KATEX_CSS: Asset = Asset::new(assets::KATEX_CSS, "text/css; charset=utf-8", KATEX_CACHE);
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
@@ -76,6 +80,9 @@ pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Respons
}
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
p if p.starts_with("/fonts/") => font(p)?,
"/katex/katex.min.js" => asset(&KATEX_JS, accepts_gzip),
"/katex/katex.min.css" => asset(&KATEX_CSS, accepts_gzip),
p if p.starts_with("/katex/") => katex_font(p)?,
_ => return None,
};
if accepts_gzip {
@@ -130,6 +137,20 @@ fn font(path: &str) -> Option<Response<Body>> {
Some(r)
}
/// `/katex/fonts/<nom>.woff2` — chemin exact (pas le dernier segment seul,
/// contrairement à `/fonts/`), comme `katexFile` dans `web_katex.go`. Le
/// reste de `/katex/` part au Go, qui rend son 404.
fn katex_font(path: &str) -> Option<Response<Body>> {
let rel = path.strip_prefix("/katex/")?;
let (_, body) = assets::KATEX_FONTS.iter().find(|(n, _)| *n == rel)?;
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(KATEX_CACHE));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
Some(r)
}
fn compress(b: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
enc.write_all(b).expect("gzip en mémoire");
@@ -193,6 +214,46 @@ mod tests {
assert!(get("/fonts/inter.ttf", false).is_none());
}
#[test]
fn katex() {
let r = get("/katex/katex.min.js", true).unwrap();
assert_eq!(hv(&r, "content-type"), "application/javascript");
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
assert_eq!(hv(&r, "content-encoding"), "gzip");
assert!(
String::from_utf8_lossy(assets::KATEX_JS).contains("version:\"0.16.11\""),
"KaTeX 0.16.11 attendu"
);
let r = get("/katex/katex.min.css", false).unwrap();
assert_eq!(hv(&r, "content-type"), "text/css; charset=utf-8");
// Chaque police woff2 demandée par la feuille est servie, non compressée.
let css = String::from_utf8_lossy(assets::KATEX_CSS);
let polices: Vec<&str> = css
.split("url(")
.skip(1)
.filter_map(|s| s.split(')').next())
.filter(|u| u.ends_with(".woff2"))
.collect();
assert!(polices.len() >= 10, "{} polices", polices.len());
for p in polices {
let r = get(&format!("/katex/{p}"), true).unwrap_or_else(|| panic!("{p}"));
assert_eq!(hv(&r, "content-type"), "font/woff2");
assert_eq!(hv(&r, "content-encoding"), "");
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
}
// Le reste part au Go (404) : ni index, ni licence, ni dernier segment seul.
for p in [
"/katex/",
"/katex/LICENSE",
"/katex/fonts/",
"/katex/fonts/absente.woff2",
"/katex/KaTeX_Main-Regular.woff2",
"/katex/x/fonts/KaTeX_Main-Regular.woff2",
] {
assert!(get(p, false).is_none(), "{p}");
}
}
#[test]
fn gzip_si_accepte() {
let r = get("/", true).unwrap();