Skip to content

Commit 1970d03

Browse files
committed
feat: math要素のレンダリングをMathML+SVGポリフィル方式に変更
- temmlでLaTeX→MathML変換(サーバーサイド) - hfmathでMathMLポリフィル(クライアントサイド、MathML非対応ブラウザのみ) - 数式参照(eref)のツールチップ・スクロール機能を実装 - mathブロック内のバックスラッシュ+改行を正しく処理 - Wikidot形式のアライメント(&)を自動でaligned環境にラップ
1 parent c7f4d9d commit 1970d03

14 files changed

Lines changed: 280 additions & 82 deletions

File tree

bun.lock

Lines changed: 13 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bunup.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export default defineWorkspace([
4545
dts: true,
4646
minify: false,
4747
clean: true,
48+
external: ["hfmath"],
49+
target: "browser",
4850
},
4951
},
5052
]);

examples/wdmock-cf/apps/main/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@wdprlib/parser": "workspace:*",
1717
"@wdprlib/render": "workspace:*",
1818
"@wdprlib/runtime": "workspace:*",
19+
"hfmath": "^0.0.2",
1920
"hono": "^4.7.5"
2021
},
2122
"devDependencies": {

packages/parser/src/parser/rules/block/math.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export const mathBlockRule: BlockRule = {
7575
}
7676

7777
// Collect LaTeX content until [[/math]]
78+
// BACKSLASH_BREAK (U+E000) was created by preprocessing from "\\\n"
79+
// In math blocks, we need to restore this as "\\\n" for LaTeX line breaks
7880
let latexSource = "";
7981

8082
while (pos < ctx.tokens.length) {
@@ -89,7 +91,12 @@ export const mathBlockRule: BlockRule = {
8991
}
9092
}
9193

92-
latexSource += token.value;
94+
// Restore BACKSLASH_BREAK to original "\\\n" for LaTeX
95+
if (token.type === "BACKSLASH_BREAK") {
96+
latexSource += "\\\n";
97+
} else {
98+
latexSource += token.value;
99+
}
93100
pos++;
94101
consumed++;
95102
}

packages/render/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"access": "public"
4040
},
4141
"dependencies": {
42-
"@wdprlib/ast": "workspace:*"
42+
"@wdprlib/ast": "workspace:*",
43+
"temml": "^0.13.1"
4344
}
4445
}
Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,114 @@
11
import type { MathData, MathInlineData } from "@wdprlib/ast";
22
import type { RenderContext } from "../context";
33
import { escapeAttr, escapeHtml } from "../escape";
4+
import temml from "temml";
5+
6+
/**
7+
* Check if LaTeX needs to be wrapped in aligned environment.
8+
* Wikidot-style math blocks with & alignment markers need this.
9+
*/
10+
function needsAlignedWrapper(latex: string): boolean {
11+
// Already has an environment
12+
if (/\\begin\s*\{/.test(latex)) {
13+
return false;
14+
}
15+
// Has alignment marker (&) but not escaped (\&) - needs aligned environment
16+
// Remove escaped ampersands first, then check for unescaped ones
17+
const withoutEscaped = latex.replace(/\\&/g, "");
18+
return withoutEscaped.includes("&");
19+
}
20+
21+
/**
22+
* Render LaTeX to MathML using temml.
23+
* Returns empty string on error.
24+
*/
25+
function renderLatexToMathML(latex: string, displayMode: boolean): string {
26+
try {
27+
// Wrap in aligned environment if needed for Wikidot-style alignment
28+
let processedLatex = latex;
29+
if (displayMode && needsAlignedWrapper(latex)) {
30+
processedLatex = `\\begin{aligned}\n${latex}\n\\end{aligned}`;
31+
}
32+
return temml.renderToString(processedLatex, {
33+
displayMode,
34+
throwOnError: false,
35+
annotate: false,
36+
});
37+
} catch {
38+
return "";
39+
}
40+
}
441

542
/** Render a block math element */
643
export function renderMath(ctx: RenderContext, data: MathData): void {
744
const index = ctx.nextEquationIndex() + 1;
45+
const latex = data["latex-source"];
46+
const mathml = renderLatexToMathML(latex, true);
47+
48+
const id = data.name ? `equation-${data.name}` : `equation-${index}`;
49+
const dataName = data.name ? ` data-name="${escapeAttr(data.name)}"` : "";
50+
51+
ctx.push(`<div class="math-block" id="${escapeAttr(id)}"${dataName}>`);
852

53+
// Equation number (only for named equations)
954
if (data.name) {
1055
ctx.push(`<span class="equation-number">(${index})</span>`);
1156
}
1257

13-
const id = data.name ? `equation-${data.name}` : `equation-${index}`;
14-
ctx.push(`<div class="math-equation" id="${escapeAttr(id)}">`);
15-
ctx.push(escapeHtml(data["latex-source"]));
58+
// Hidden LaTeX source (for polyfill)
59+
ctx.push(`<code class="math-source" hidden aria-hidden="true">`);
60+
ctx.push(escapeHtml(latex));
61+
ctx.push(`</code>`);
62+
63+
// MathML output
64+
ctx.push(`<span class="math-render">`);
65+
if (mathml) {
66+
ctx.push(mathml);
67+
} else {
68+
// Fallback: display error
69+
ctx.push(`<span class="math-error">`);
70+
ctx.push(escapeHtml(latex));
71+
ctx.push(`</span>`);
72+
}
73+
ctx.push(`</span>`);
74+
1675
ctx.push("</div>");
1776
}
1877

1978
/** Render an inline math element */
2079
export function renderMathInline(ctx: RenderContext, data: MathInlineData): void {
21-
ctx.push(`<span class="math-inline">$`);
22-
ctx.push(escapeHtml(data["latex-source"]));
23-
ctx.push("$</span>");
80+
const latex = data["latex-source"];
81+
const mathml = renderLatexToMathML(latex, false);
82+
83+
ctx.push(`<span class="math-inline">`);
84+
85+
// Hidden LaTeX source (for polyfill)
86+
ctx.push(`<code class="math-source" hidden aria-hidden="true">`);
87+
ctx.push(escapeHtml(latex));
88+
ctx.push(`</code>`);
89+
90+
// MathML output
91+
ctx.push(`<span class="math-render">`);
92+
if (mathml) {
93+
ctx.push(mathml);
94+
} else {
95+
// Fallback: display with $ delimiters
96+
ctx.push(`<span class="math-error">$`);
97+
ctx.push(escapeHtml(latex));
98+
ctx.push(`$</span>`);
99+
}
100+
ctx.push(`</span>`);
101+
102+
ctx.push("</span>");
24103
}
25104

26105
/** Render an equation reference (link to named equation) */
27106
export function renderEquationRef(ctx: RenderContext, name: string): void {
28-
// Create a link to the named equation
29-
// The equation index is not available at render time without tracking,
30-
// so we generate a link that can be resolved client-side or with post-processing
31107
const id = `equation-${name}`;
32-
ctx.push(`<a class="equation-ref" href="#${escapeAttr(id)}">`);
108+
ctx.push(`<span class="eref" data-target="${escapeAttr(id)}">`);
109+
ctx.push(`<a class="eref-link" href="#${escapeAttr(id)}">`);
33110
ctx.push(escapeHtml(name));
34-
ctx.push("</a>");
111+
ctx.push(`</a>`);
112+
ctx.push(`<span class="eref-tooltip" aria-hidden="true"></span>`);
113+
ctx.push("</span>");
35114
}

packages/runtime/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,8 @@
3636
},
3737
"publishConfig": {
3838
"access": "public"
39+
},
40+
"dependencies": {
41+
"hfmath": "^0.0.2"
3942
}
4043
}

packages/runtime/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function initWdprRuntime(options?: RuntimeOptions): WdprRuntime {
3030
cleanups.push(initJoin(root, options));
3131
cleanups.push(initHtmlBlockResize(root));
3232

33-
cleanups.push(initMath(root, options));
33+
cleanups.push(initMath(root));
3434

3535
initOdate(root);
3636
initEmail(root);

0 commit comments

Comments
 (0)