This commit is contained in:
@@ -118,6 +118,76 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
||||
|
||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule)
|
||||
|
||||
/**
|
||||
* Math callout blocks: a fenced block tagged with one of these keywords
|
||||
* (```theorem, ```example, ...) renders as a blockquote-styled box with the
|
||||
* keyword's colour on its left edge instead of as code. The body is parsed as
|
||||
* ordinary Markdown; any text after the keyword titles the box
|
||||
* (```theorem Pythagoras). Colours live in _tokens.scss, shapes in _prose.scss.
|
||||
* The label names the kind of statement *the article* makes, so it is written
|
||||
* in the article's language (carried through the render env) — the reader's
|
||||
* interface language never touches the page's text. Unknown languages keep English.
|
||||
*/
|
||||
type CalloutKind = 'example' | 'theorem' | 'corollary' | 'definition' | 'proof' | 'proposition' | 'lemma'
|
||||
|
||||
const CALLOUT_EN: Record<CalloutKind, string> = {
|
||||
example: 'Example',
|
||||
theorem: 'Theorem',
|
||||
corollary: 'Corollary',
|
||||
definition: 'Definition',
|
||||
proof: 'Proof',
|
||||
proposition: 'Proposition',
|
||||
lemma: 'Lemma',
|
||||
}
|
||||
|
||||
const CALLOUT_LABELS: Record<string, Partial<Record<CalloutKind, string>>> = {
|
||||
fr: { theorem: 'Théorème', corollary: 'Corollaire', definition: 'Définition', proof: 'Démonstration', example: 'Exemple', lemma: 'Lemme' },
|
||||
de: { theorem: 'Satz', corollary: 'Korollar', proof: 'Beweis', example: 'Beispiel' },
|
||||
es: { theorem: 'Teorema', corollary: 'Corolario', definition: 'Definición', proof: 'Demostración', proposition: 'Proposición', example: 'Ejemplo', lemma: 'Lema' },
|
||||
ca: { theorem: 'Teorema', corollary: 'Corol·lari', definition: 'Definició', proof: 'Demostració', proposition: 'Proposició', example: 'Exemple', lemma: 'Lema' },
|
||||
pt: { theorem: 'Teorema', corollary: 'Corolário', definition: 'Definição', proof: 'Demonstração', proposition: 'Proposição', example: 'Exemplo', lemma: 'Lema' },
|
||||
it: { theorem: 'Teorema', corollary: 'Corollario', definition: 'Definizione', proof: 'Dimostrazione', proposition: 'Proposizione', example: 'Esempio' },
|
||||
nl: { theorem: 'Stelling', corollary: 'Gevolg', proof: 'Bewijs', example: 'Voorbeeld', definition: 'Definitie', proposition: 'Propositie' },
|
||||
pl: { theorem: 'Twierdzenie', corollary: 'Wniosek', definition: 'Definicja', proof: 'Dowód', proposition: 'Propozycja', example: 'Przykład', lemma: 'Lemat' },
|
||||
ru: { theorem: 'Теорема', corollary: 'Следствие', definition: 'Определение', proof: 'Доказательство', proposition: 'Утверждение', example: 'Пример', lemma: 'Лемма' },
|
||||
uk: { theorem: 'Теорема', corollary: 'Наслідок', definition: 'Означення', proof: 'Доведення', proposition: 'Твердження', example: 'Приклад', lemma: 'Лема' },
|
||||
tr: { theorem: 'Teorem', corollary: 'Sonuç', definition: 'Tanım', proof: 'İspat', proposition: 'Önerme', example: 'Örnek' },
|
||||
ar: { theorem: 'مبرهنة', corollary: 'نتيجة', definition: 'تعريف', proof: 'برهان', proposition: 'قضية', example: 'مثال', lemma: 'ليما' },
|
||||
fa: { theorem: 'قضیه', corollary: 'نتیجه', definition: 'تعریف', proof: 'اثبات', proposition: 'گزاره', example: 'مثال', lemma: 'لم' },
|
||||
hi: { theorem: 'प्रमेय', corollary: 'निष्कर्ष', definition: 'परिभाषा', proof: 'प्रमाण', example: 'उदाहरण', proposition: 'कथन', lemma: 'उपप्रमेय' },
|
||||
zh: { theorem: '定理', corollary: '推论', definition: '定义', proof: '证明', proposition: '命题', lemma: '引理', example: '例' },
|
||||
ja: { theorem: '定理', corollary: '系', definition: '定義', proof: '証明', proposition: '命題', lemma: '補題', example: '例' },
|
||||
ko: { theorem: '정리', corollary: '계', definition: '정의', proof: '증명', proposition: '명제', lemma: '보조정리', example: '예' },
|
||||
}
|
||||
|
||||
function isCalloutWord(word: string): word is CalloutKind {
|
||||
return Object.hasOwn(CALLOUT_EN, word)
|
||||
}
|
||||
|
||||
const defaultFence = md.renderer.rules.fence!.bind(md.renderer)
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]!
|
||||
const info = token.info.trim()
|
||||
const space = info.search(/\s/)
|
||||
const word = (space === -1 ? info : info.slice(0, space)).toLowerCase()
|
||||
if (!isCalloutWord(word)) return defaultFence(tokens, idx, options, env, self)
|
||||
|
||||
// renderMarkdown hands the article's language through the env; anything
|
||||
// rendered without one (or in a language not listed above) keeps English
|
||||
const lang = (env as { articleLanguage?: unknown } | undefined)?.articleLanguage
|
||||
const labels = (typeof lang === 'string' && CALLOUT_LABELS[lang.trim().toLowerCase()]) || CALLOUT_EN
|
||||
const kind = labels[word] ?? CALLOUT_EN[word]
|
||||
const title = space === -1 ? '' : info.slice(space + 1).trim()
|
||||
const label = title ? `${kind} — ${md.utils.escapeHtml(title)}` : kind
|
||||
const body = md.render(token.content, env).trim()
|
||||
// a native <details> makes the label row the toggle: it works on the first
|
||||
// paint and from the keyboard, and MarkdownView animates the fold; every
|
||||
// box starts open. The body is one wrapper so the fold has a single thing
|
||||
// to measure and shrink.
|
||||
return `<blockquote class="md-box md-box--${word}"><details open><summary class="md-box-title">${label}</summary><div class="md-box-body">${body}</div></details></blockquote>\n`
|
||||
}
|
||||
|
||||
/** Best-effort plain text of an inline token (for TOC labels). */
|
||||
function inlineText(inline: Token): string {
|
||||
let out = ''
|
||||
@@ -144,8 +214,11 @@ const sanitizeConfig = {
|
||||
* `namespace` prefixes the heading ids (the reader passes the article slug),
|
||||
* so that the articles open side by side never hand the document two
|
||||
* headings with the same id.
|
||||
*
|
||||
* `language` is the article's own language — callout boxes (```theorem, …)
|
||||
* write their labels in it, never in the interface language.
|
||||
*/
|
||||
export function renderMarkdown(source: string, namespace?: string): RenderedMarkdown {
|
||||
export function renderMarkdown(source: string, namespace?: string, language?: string): RenderedMarkdown {
|
||||
const src = source ?? ''
|
||||
const tokens = md.parse(src, {})
|
||||
const toc: TocEntry[] = []
|
||||
@@ -169,7 +242,7 @@ export function renderMarkdown(source: string, namespace?: string): RenderedMark
|
||||
if (level >= 2 && level <= 3) toc.push({ id: `${prefix}${id}`, text, level })
|
||||
}
|
||||
|
||||
const raw = md.renderer.render(tokens, md.options, {})
|
||||
const raw = md.renderer.render(tokens, md.options, { articleLanguage: language })
|
||||
const html = DOMPurify.sanitize(raw, sanitizeConfig) as unknown as string
|
||||
return { html, toc }
|
||||
}
|
||||
|
||||
308
frontend/app/utils/plot-expression.ts
Normal file
308
frontend/app/utils/plot-expression.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/* The graphing calculator's little algebra: a hand-rolled tokenizer and
|
||||
recursive-descent parser that reads an expression in x — "sin(x) + x / 4",
|
||||
"2^x", "x(x+1)" — and compiles it to a function the canvas evaluates pixel
|
||||
by pixel. No library: the grammar is small enough for one file, and the
|
||||
parser can be strict about what it promises — everything it accepts is a
|
||||
real function of x, and everything else is refused with a reason.
|
||||
|
||||
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
|
||||
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
|
||||
the square; then ^, right-associative, its exponent may carry a sign so
|
||||
2^-x reads. Functions call, constants stand, parentheses group. */
|
||||
|
||||
export type PlotFunction = (x: number) => number
|
||||
|
||||
/* why a text was refused: nothing to read, a name the calculator does not
|
||||
speak, or a shape the grammar rejects */
|
||||
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
|
||||
|
||||
export type CompiledPlot =
|
||||
| { ok: true; fn: PlotFunction }
|
||||
| { ok: false; reason: PlotErrorReason }
|
||||
|
||||
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '(' | ')' | ','
|
||||
|
||||
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
|
||||
|
||||
type Node =
|
||||
| { kind: 'num'; value: number }
|
||||
| { kind: 'var' }
|
||||
| { kind: 'const'; value: number }
|
||||
| { kind: 'neg'; arg: Node }
|
||||
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
|
||||
| { kind: 'call'; name: string; args: Node[] }
|
||||
|
||||
/* the constants a name may stand for */
|
||||
const CONSTANTS: Record<string, number> = {
|
||||
pi: Math.PI,
|
||||
tau: Math.PI * 2,
|
||||
e: Math.E,
|
||||
phi: (1 + Math.sqrt(5)) / 2,
|
||||
}
|
||||
|
||||
/* the functions a name may call — all on the variable x, ln and log split
|
||||
the natural and the base-10 way as calculators write them */
|
||||
const FUNCTIONS: Record<string, (...args: number[]) => number> = {
|
||||
sin: Math.sin,
|
||||
cos: Math.cos,
|
||||
tan: Math.tan,
|
||||
asin: Math.asin,
|
||||
acos: Math.acos,
|
||||
atan: Math.atan,
|
||||
sinh: Math.sinh,
|
||||
cosh: Math.cosh,
|
||||
tanh: Math.tanh,
|
||||
ln: Math.log,
|
||||
log: Math.log10,
|
||||
log2: Math.log2,
|
||||
log10: Math.log10,
|
||||
sqrt: Math.sqrt,
|
||||
cbrt: Math.cbrt,
|
||||
abs: Math.abs,
|
||||
exp: Math.exp,
|
||||
floor: Math.floor,
|
||||
ceil: Math.ceil,
|
||||
round: Math.round,
|
||||
sign: Math.sign,
|
||||
min: (...a) => Math.min(...a),
|
||||
max: (...a) => Math.max(...a),
|
||||
mod: (a, b) => a % b,
|
||||
}
|
||||
|
||||
/* min and max take as many arguments as they are given; mod takes two;
|
||||
everything else wants exactly one */
|
||||
function arityOk(name: string, args: number): boolean {
|
||||
if (name === 'min' || name === 'max') return args >= 1
|
||||
if (name === 'mod') return args === 2
|
||||
return args === 1
|
||||
}
|
||||
|
||||
class PlotParseError extends Error {
|
||||
reason: Exclude<PlotErrorReason, 'empty'>
|
||||
constructor(reason: 'unknown' | 'syntax') {
|
||||
super(reason)
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/* the input wears its "y =" beside the field; a reader who types it too is
|
||||
answered the same — the rest is what gets plotted. f(x) = reads too. */
|
||||
function stripAssignment(src: string): string {
|
||||
return src.replace(/^\s*(?:y|f\s*\(\s*x\s*\))\s*=/i, '')
|
||||
}
|
||||
|
||||
function tokenize(src: string): Token[] {
|
||||
/* the typographic shapes a reader may paste in stand for the plain ones */
|
||||
const s = src
|
||||
.replace(/[×·∗]/g, '*')
|
||||
.replace(/÷/g, '/')
|
||||
.replace(/[−–]/g, '-')
|
||||
.replace(/π/g, 'pi')
|
||||
.replace(/τ/g, 'tau')
|
||||
|
||||
const toks: Token[] = []
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
const c = s[i]!
|
||||
if (c === ' ' || c === '\t' || c === '\n') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ((c >= '0' && c <= '9') || c === '.') {
|
||||
const start = i
|
||||
let dot = false
|
||||
while (i < s.length) {
|
||||
const d = s[i]!
|
||||
if (d >= '0' && d <= '9') i++
|
||||
else if (d === '.' && !dot) {
|
||||
dot = true
|
||||
i++
|
||||
} else break
|
||||
}
|
||||
const value = Number.parseFloat(s.slice(start, i))
|
||||
if (!Number.isFinite(value)) throw new PlotParseError('syntax')
|
||||
toks.push({ t: 'num', value })
|
||||
continue
|
||||
}
|
||||
if (/[a-z_]/i.test(c)) {
|
||||
const start = i
|
||||
while (i < s.length && /[a-z0-9_]/i.test(s[i]!)) i++
|
||||
toks.push({ t: 'id', name: s.slice(start, i).toLowerCase() })
|
||||
continue
|
||||
}
|
||||
if (c === '*' && s[i + 1] === '*') {
|
||||
i += 2
|
||||
toks.push({ t: '^' }) // ** as an exponent, the programming shape
|
||||
continue
|
||||
}
|
||||
if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%' || c === '^') {
|
||||
toks.push({ t: c as Op })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '(' || c === ')' || c === ',') {
|
||||
toks.push({ t: c as Op })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
throw new PlotParseError('syntax')
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
interface Parser {
|
||||
toks: Token[]
|
||||
pos: number
|
||||
}
|
||||
|
||||
const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null
|
||||
|
||||
const startsAtom = (tk: Token): boolean => tk.t === 'num' || tk.t === 'id' || tk.t === '('
|
||||
|
||||
function parseExpr(p: Parser): Node {
|
||||
let node = parseTerm(p)
|
||||
for (;;) {
|
||||
const tk = peek(p)
|
||||
if (!tk || (tk.t !== '+' && tk.t !== '-')) break
|
||||
p.pos++
|
||||
node = { kind: 'bin', op: tk.t, a: node, b: parseTerm(p) }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function parseTerm(p: Parser): Node {
|
||||
let node = parseUnary(p)
|
||||
for (;;) {
|
||||
const tk = peek(p)
|
||||
if (!tk) break
|
||||
if (tk.t === '*' || tk.t === '/' || tk.t === '%') {
|
||||
p.pos++
|
||||
node = { kind: 'bin', op: tk.t, a: node, b: parseUnary(p) }
|
||||
continue
|
||||
}
|
||||
/* nothing between two factors and a factor following: that's a
|
||||
multiplication — 2x, 3(x+1), x sin(x) */
|
||||
if (startsAtom(tk)) {
|
||||
node = { kind: 'bin', op: '*', a: node, b: parseUnary(p) }
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function parseUnary(p: Parser): Node {
|
||||
const tk = peek(p)
|
||||
if (tk && tk.t === '+') {
|
||||
p.pos++
|
||||
return parseUnary(p)
|
||||
}
|
||||
if (tk && tk.t === '-') {
|
||||
p.pos++
|
||||
return { kind: 'neg', arg: parseUnary(p) }
|
||||
}
|
||||
return parsePower(p)
|
||||
}
|
||||
|
||||
function parsePower(p: Parser): Node {
|
||||
const base = parseAtom(p)
|
||||
const tk = peek(p)
|
||||
if (tk && tk.t === '^') {
|
||||
p.pos++
|
||||
// right-associative, and the exponent may carry its own sign
|
||||
return { kind: 'bin', op: '^', a: base, b: parseUnary(p) }
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function parseAtom(p: Parser): Node {
|
||||
const tk = peek(p)
|
||||
if (!tk) throw new PlotParseError('syntax')
|
||||
if (tk.t === 'num') {
|
||||
p.pos++
|
||||
return { kind: 'num', value: tk.value }
|
||||
}
|
||||
if (tk.t === '(') {
|
||||
p.pos++
|
||||
const inner = parseExpr(p)
|
||||
const close = peek(p)
|
||||
if (!close || close.t !== ')') throw new PlotParseError('syntax')
|
||||
p.pos++
|
||||
return inner
|
||||
}
|
||||
if (tk.t === 'id') {
|
||||
p.pos++
|
||||
if (tk.name === 'x') return { kind: 'var' }
|
||||
if (Object.hasOwn(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! }
|
||||
if (Object.hasOwn(FUNCTIONS, tk.name)) {
|
||||
const open = peek(p)
|
||||
if (!open || open.t !== '(') throw new PlotParseError('syntax')
|
||||
p.pos++
|
||||
const args: Node[] = [parseExpr(p)]
|
||||
while (peek(p)?.t === ',') {
|
||||
p.pos++
|
||||
args.push(parseExpr(p))
|
||||
}
|
||||
const close = peek(p)
|
||||
if (!close || close.t !== ')') throw new PlotParseError('syntax')
|
||||
p.pos++
|
||||
if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax')
|
||||
return { kind: 'call', name: tk.name, args }
|
||||
}
|
||||
throw new PlotParseError('unknown')
|
||||
}
|
||||
throw new PlotParseError('syntax')
|
||||
}
|
||||
|
||||
function evaluate(node: Node, x: number): number {
|
||||
switch (node.kind) {
|
||||
case 'num':
|
||||
return node.value
|
||||
case 'var':
|
||||
return x
|
||||
case 'const':
|
||||
return node.value
|
||||
case 'neg':
|
||||
return -evaluate(node.arg, x)
|
||||
case 'call': {
|
||||
const fn = FUNCTIONS[node.name]!
|
||||
return fn(...node.args.map((a) => evaluate(a, x)))
|
||||
}
|
||||
case 'bin': {
|
||||
const a = evaluate(node.a, x)
|
||||
const b = evaluate(node.b, x)
|
||||
switch (node.op) {
|
||||
case '+':
|
||||
return a + b
|
||||
case '-':
|
||||
return a - b
|
||||
case '*':
|
||||
return a * b
|
||||
case '/':
|
||||
return a / b
|
||||
case '%':
|
||||
return a % b
|
||||
case '^':
|
||||
return Math.pow(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* read one expression; a refusal says whether a name was not known or the
|
||||
shape was not a function — an empty text is the caller's to treat as
|
||||
"nothing asked for", not as an error */
|
||||
export function compilePlotExpression(src: string): CompiledPlot {
|
||||
if (src.length > 400) return { ok: false, reason: 'syntax' }
|
||||
try {
|
||||
const toks = tokenize(stripAssignment(src))
|
||||
if (toks.length === 0) return { ok: false, reason: 'empty' }
|
||||
const p: Parser = { toks, pos: 0 }
|
||||
const ast = parseExpr(p)
|
||||
if (p.pos !== toks.length) throw new PlotParseError('syntax')
|
||||
return { ok: true, fn: (x) => evaluate(ast, x) }
|
||||
} catch (err) {
|
||||
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
|
||||
return { ok: false, reason: 'syntax' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user