daily-question 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -109,351 +109,119 @@ window.__ModuleLoader__.load({
109
109
  return { text: '未作答', tone: 'idle' }
110
110
  }
111
111
 
112
- /* ============================== LaTeX ============================== */
113
- const LATEX_SYMBOLS = {
114
- alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
115
- theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
116
- rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
117
- Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ',
118
- Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
119
- infty: '∞', cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗', star: '⋆', oplus: '⊕', otimes: '⊗',
120
- leq: '≤', le: '≤', geq: '≥', ge: '≥', neq: '≠', ne: '≠', approx: '≈', equiv: '≡', sim: '∼',
121
- propto: '∝', ll: '≪', gg: '≫', leqslant: '≤', geqslant: '≥',
122
- to: '→', rightarrow: '→', leftarrow: '←', leftrightarrow: '↔', Rightarrow: '⇒', Leftarrow: '⇐',
123
- Leftrightarrow: '⇔', mapsto: '↦', implies: '⟹', iff: '⟺',
124
- partial: '∂', nabla: '∇', forall: '∀', exists: '∃', nexists: '∄', neg: '¬', in: '∈', notin: '∉',
125
- subset: '⊂', subseteq: '⊆', supset: '⊃', supseteq: '⊇', cup: '∪', cap: '∩', emptyset: '∅',
126
- varnothing: '∅', setminus: '∖', land: '∧', lor: '∨',
127
- mid: '∣', nmid: '∤', vert: '|', lvert: '|', rvert: '|',
128
- Vert: '‖', lVert: '‖', rVert: '‖', colon: ':', '|': '‖',
129
- ldots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', dots: '…',
130
- angle: '∠', perp: '⊥', parallel: '∥', circ: '∘', degree: '°', prime: '′',
131
- lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋', langle: '⟨', rangle: '⟩',
132
- ell: 'ℓ', Re: 'ℜ', Im: 'ℑ', hbar: 'ℏ',
133
- therefore: '∴', because: '∵', triangle: '△', square: '□',
134
- bullet: '∙', ni: '∋', backslash: '\\', Box: '□', bigcirc: '○', dagger: '†', ddagger: '‡',
135
- quad: '\u2003', qquad: '\u2003\u2003',
136
- '%': '%', '&': '&', '#': '#', '_': '_', '{': '{', '}': '}',
137
- ' ': ' ', ',': '\u2009', ';': '\u2005', ':': '\u2005', '!': '',
138
- }
139
- const LATEX_FUNCS = new Set(['sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan',
140
- 'sinh', 'cosh', 'tanh', 'ln', 'log', 'lg', 'exp', 'max', 'min', 'sup', 'inf', 'det', 'gcd', 'lcm',
141
- 'deg', 'dim', 'ker', 'arg', 'Pr'])
142
- const LATEX_BIGOPS = {
143
- sum: '∑', prod: '∏', int: '∫', iint: '∬', iiint: '∭', oint: '∮',
144
- lim: 'lim', limsup: 'lim sup', liminf: 'lim inf', bigcup: '⋃', bigcap: '⋂',
145
- }
146
- const DOUBLE_STRUCK = { R: 'ℝ', N: 'ℕ', Z: 'ℤ', Q: 'ℚ', C: 'ℂ', P: 'ℙ' }
147
-
148
- /** 连写撇号对应的 prime 字符(1~4 阶,更多阶用 4 阶 + 单撇补)。 */
149
- const PRIME_CHARS = { 1: '\u2032', 2: '\u2033', 3: '\u2034', 4: '\u2057' }
150
-
151
- /**
152
- * 数学模式里的字符修正。只处理公式内部的裸文本(\text / \mathrm 走原样,不受影响):
153
- *
154
- * 1. 连字符 `-` 在 LaTeX 数学模式里是**减号**,换成 U+2212 MINUS SIGN。
155
- * 留成 U+002D 的话,Latin Modern Math 会画成一个短横,看着像分隔符。
156
- * 2. 撇号 `'`(含中文输入法常见的 ’)是**导数记号**,换成 U+2032 起的 prime
157
- * 连写:' → ′、'' → ″、''' → ‴、'''' → ⁗。
158
- * 3. ASCII 字母换成 Unicode 数学斜体码位(U+1D434… 大写、U+1D44E… 小写,
159
- * 例外 h → U+210E),Latin Modern Math 与 Cambria Math 都完整覆盖,
160
- * 因此变量是真斜体,而数字、运算符、函数名(\sin 等)保持直立。
161
- */
162
- function toMathText(text) {
163
- return String(text)
164
- .replace(/['\u2018\u2019]+/g, (run) => {
165
- const count = [...run].length
166
- if (count <= 4) return PRIME_CHARS[count]
167
- return '\u2057' + '\u2032'.repeat(count - 4)
168
- })
169
- .replace(/-/g, '\u2212')
170
- .replace(/\*/g, '\u2217')
171
- .replace(/\.{3,}/g, '\u2026')
172
- .replace(/[A-Za-z]/g, (ch) => {
173
- if (ch === 'h') return '\u210E'
174
- const code = ch.charCodeAt(0)
175
- if (code >= 97 && code <= 122) return String.fromCodePoint(0x1d44e + (code - 97))
176
- return String.fromCodePoint(0x1d434 + (code - 65))
177
- })
178
- }
179
-
180
- function readGroup(cur) {
181
- // cur.src[cur.i] === '{'
182
- let depth = 0
183
- let j = cur.i
184
- const start = cur.i + 1
185
- while (j < cur.src.length) {
186
- const c = cur.src[j]
187
- if (c === '\\') { j += 2; continue }
188
- if (c === '{') depth += 1
189
- else if (c === '}') {
190
- depth -= 1
191
- if (depth === 0) {
192
- cur.i = j + 1
193
- return cur.src.slice(start, j)
194
- }
195
- }
196
- j += 1
112
+ /* ============================== 公式(MathJax 3) ============================== */
113
+ // MathJax 的 SVG 输出自带字形路径,不依赖任何字体文件;单文件 bundle 随包分发,
114
+ // 由宿主在 /api/daily-question/vendor/tex-svg.js 提供。
115
+ const MATHJAX_SRC = '/api/daily-question/vendor/tex-svg.js'
116
+ const mathState = { status: 'idle' }
117
+ const mathWatchers = new Set()
118
+
119
+ function notifyMath() {
120
+ for (const watcher of [...mathWatchers]) {
121
+ try { watcher() } catch (error) { /* 单个订阅出错不影响其它 */ }
197
122
  }
198
- cur.i = cur.src.length
199
- return cur.src.slice(start)
200
123
  }
201
124
 
202
- function readOptionalGroup(cur) {
203
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
204
- if (cur.src[cur.i] !== '[') return null
205
- const end = cur.src.indexOf(']', cur.i)
206
- if (end < 0) return null
207
- const body = cur.src.slice(cur.i + 1, end)
208
- cur.i = end + 1
209
- return body
125
+ function subscribeMath(fn) {
126
+ mathWatchers.add(fn)
127
+ return () => { mathWatchers.delete(fn) }
210
128
  }
211
129
 
212
- function readArg(cur) {
213
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
214
- if (cur.src[cur.i] === '{') return latexNodes(readGroup(cur))
215
- const rest = cur.src.slice(cur.i)
216
- const command = /^\\[A-Za-z]+/.exec(rest)
217
- if (command) {
218
- cur.i += command[0].length
219
- return latexNodes(command[0])
130
+ function ensureMathJax() {
131
+ if (mathState.status !== 'idle') return
132
+ mathState.status = 'loading'
133
+ window.MathJax = {
134
+ startup: { typeset: false },
135
+ // 每条公式自带 <defs>,注入 innerHTML 后不依赖全局字体缓存
136
+ svg: { fontCache: 'local' },
220
137
  }
221
- if (rest[0] === '\\') {
222
- cur.i += 2
223
- return latexNodes(rest.slice(0, 2))
138
+ const script = document.createElement('script')
139
+ script.src = MATHJAX_SRC
140
+ script.async = true
141
+ script.onload = () => {
142
+ const startup = window.MathJax && window.MathJax.startup
143
+ const done = () => {
144
+ mathState.status = 'ready'
145
+ ensureMathStylesheet()
146
+ notifyMath()
147
+ }
148
+ const failed = () => { mathState.status = 'failed'; notifyMath() }
149
+ if (startup && startup.promise) startup.promise.then(done, failed)
150
+ else done()
224
151
  }
225
- cur.i += 1
226
- return latexNodes(rest.slice(0, 1))
152
+ script.onerror = () => { mathState.status = 'failed'; notifyMath() }
153
+ document.head.appendChild(script)
227
154
  }
228
155
 
229
- function readLimits(cur) {
230
- let sub = null
231
- let sup = null
232
- for (let guard = 0; guard < 4; guard += 1) {
233
- const save = cur.i
234
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
235
- const c = cur.src[cur.i]
236
- if (c === '_' && !sub) { cur.i += 1; sub = readArg(cur); continue }
237
- if (c === '^' && !sup) { cur.i += 1; sup = readArg(cur); continue }
238
- cur.i = save
239
- break
156
+ /** SVG 输出依赖 MathJax 自己生成的样式表;手动调用 tex2svg 时不会自动插,补一次。 */
157
+ function ensureMathStylesheet() {
158
+ try {
159
+ const mathJax = window.MathJax
160
+ if (!mathJax || typeof mathJax.svgStylesheet !== 'function') return
161
+ if (document.getElementById('MJX-SVG-styles')) return
162
+ const sheet = mathJax.svgStylesheet()
163
+ if (sheet) document.head.appendChild(sheet)
164
+ } catch (error) {
165
+ // 没有样式表也能显示,只是对齐略差,不阻断渲染
240
166
  }
241
- return { sub, sup }
242
- }
243
-
244
- function readMatrix(cur, env) {
245
- const marker = `\\end{${env}}`
246
- const end = cur.src.indexOf(marker, cur.i)
247
- const body = end < 0 ? cur.src.slice(cur.i) : cur.src.slice(cur.i, end)
248
- cur.i = end < 0 ? cur.src.length : end + marker.length
249
- const rows = body.split(/\\\\/).map((row) => row.trim()).filter((row) => row.length)
250
- const cells = rows.map((row) => row.split('&').map((cell) => latexNodes(cell.trim())))
251
- const table = h('table', { className: 'kd-matrix', key: nextKey('mx') },
252
- h('tbody', null, cells.map((row, rowIndex) => h('tr', { key: rowIndex },
253
- row.map((cell, cellIndex) => h('td', { key: cellIndex }, cell))))))
254
- const wrapCls = env === 'cases' ? 'kd-cases'
255
- : env === 'bmatrix' ? 'kd-bmatrix'
256
- : env === 'vmatrix' ? 'kd-vmatrix'
257
- : env === 'pmatrix' ? 'kd-pmatrix' : 'kd-matrixwrap'
258
- const open = env === 'cases' ? '{' : env === 'bmatrix' ? '[' : env === 'pmatrix' ? '(' : ''
259
- const close = env === 'bmatrix' ? ']' : env === 'pmatrix' ? ')' : ''
260
- const children = []
261
- if (open) children.push(h('span', { className: 'kd-delim', key: 'open' }, open))
262
- children.push(table)
263
- if (close) children.push(h('span', { className: 'kd-delim', key: 'close' }, close))
264
- return h('span', { className: `kd-matrixbox ${wrapCls}`, key: nextKey('mb') }, children)
265
- }
266
-
267
- function latexNodes(src) {
268
- const cur = { src: String(src || ''), i: 0 }
269
- return latexSeq(cur, '')
270
167
  }
271
168
 
272
- function latexSeq(cur, stopChars) {
273
- const out = []
274
- let buf = ''
275
- const flush = () => {
276
- if (buf) {
277
- out.push(toMathText(buf))
278
- buf = ''
279
- }
280
- }
281
- while (cur.i < cur.src.length) {
282
- const c = cur.src[cur.i]
283
- if (stopChars && stopChars.includes(c)) break
284
- if (c === '}') { buf += c; cur.i += 1; continue }
285
- if (c === '{') {
286
- flush()
287
- out.push(...latexNodes(readGroup(cur)))
288
- continue
289
- }
290
- if (c === '^' || c === '_') {
291
- flush()
292
- cur.i += 1
293
- const arg = readArg(cur)
294
- out.push(h(c === '^' ? 'sup' : 'sub', { className: c === '^' ? 'kd-sup' : 'kd-sub', key: nextKey('sc') }, arg))
295
- continue
296
- }
297
- if (c === '&') {
298
- flush()
299
- cur.i += 1
300
- out.push(h('span', { className: 'kd-colsep', key: nextKey('cs') }, '\u2003'))
301
- continue
302
- }
303
- if (c === '~') {
304
- buf += '\u00a0'
305
- cur.i += 1
306
- continue
307
- }
308
- if (c === '\\') {
309
- const rest = cur.src.slice(cur.i)
310
- const command = /^\\([A-Za-z]+)/.exec(rest)
311
- if (command) {
312
- flush()
313
- cur.i += command[0].length
314
- const node = latexCommand(cur, command[1])
315
- if (node !== null && node !== undefined) out.push(node)
316
- continue
317
- }
318
- const symbol = rest[1]
319
- cur.i += symbol === undefined ? 1 : 2
320
- if (symbol === '\\') {
321
- flush()
322
- out.push(h('br', { key: nextKey('br') }))
323
- continue
324
- }
325
- if (LATEX_SYMBOLS[symbol] !== undefined) buf += LATEX_SYMBOLS[symbol]
326
- else if (symbol !== undefined) buf += symbol
327
- continue
328
- }
329
- if (c === ' ') { buf += ' '; cur.i += 1; continue }
330
- buf += c
331
- cur.i += 1
332
- }
333
- flush()
334
- return out
169
+ /** 让组件跟着公式引擎的加载状态重渲染。 */
170
+ function useMathJaxStatus() {
171
+ const [status, setStatus] = useState(mathState.status)
172
+ useEffect(() => {
173
+ ensureMathJax()
174
+ const update = () => setStatus(mathState.status)
175
+ update()
176
+ return subscribeMath(update)
177
+ }, [])
178
+ return status
335
179
  }
336
180
 
337
- function latexCommand(cur, name) {
338
- if (LATEX_BIGOPS[name]) {
339
- const { sub, sup } = readLimits(cur)
340
- const symbol = LATEX_BIGOPS[name]
341
- return h('span', { className: 'kd-op', key: nextKey('op') },
342
- sup ? h('span', { className: 'kd-op-lim', key: 'up' }, sup) : null,
343
- h('span', { className: 'kd-op-sym', key: 'sym' }, symbol),
344
- sub ? h('span', { className: 'kd-op-lim', key: 'down' }, sub) : null)
345
- }
346
- if (name === 'frac' || name === 'dfrac' || name === 'tfrac' || name === 'cfrac') {
347
- const numerator = readArg(cur)
348
- const denominator = readArg(cur)
349
- return h('span', { className: 'kd-frac', key: nextKey('fr') },
350
- h('span', { className: 'kd-frac-n', key: 'n' }, numerator),
351
- h('span', { className: 'kd-frac-d', key: 'd' }, denominator))
352
- }
353
- // \boxed{...}(amsmath 常用的“把答案框起来”)画一个方框
354
- if (name === 'boxed' || name === 'fbox' || name === 'framebox') {
355
- return h('span', { className: 'kd-boxed', key: nextKey('bx') }, readArg(cur))
356
- }
357
- // \binom{n}{k}:无分数线的上下堆叠 + 圆括号
358
- if (name === 'binom' || name === 'dbinom' || name === 'tbinom') {
359
- const top = readArg(cur)
360
- const bottom = readArg(cur)
361
- return h('span', { className: 'kd-binom', key: nextKey('bn') },
362
- h('span', { className: 'kd-delim', key: 'l' }, '('),
363
- h('span', { className: 'kd-frac kd-binom-body', key: 'b' },
364
- h('span', { className: 'kd-frac-n', key: 'n' }, top),
365
- h('span', { className: 'kd-frac-d', key: 'd' }, bottom)),
366
- h('span', { className: 'kd-delim', key: 'r' }, ')'))
367
- }
368
- if (name === 'sqrt') {
369
- const index = readOptionalGroup(cur)
370
- const body = readArg(cur)
371
- return h('span', { className: 'kd-sqrt', key: nextKey('sq') },
372
- h('span', { className: 'kd-sqrt-sign', key: 's' }, '√'),
373
- index ? h('sup', { className: 'kd-sqrt-index', key: 'i' }, latexNodes(index)) : null,
374
- h('span', { className: 'kd-sqrt-body', key: 'b' }, body))
375
- }
376
- if (name === 'text' || name === 'textrm' || name === 'mbox' || name === 'mathrm' || name === 'operatorname') {
377
- const raw = readGroup(cur)
378
- return h('span', { className: 'kd-roman', key: nextKey('rm') }, raw.replace(/\\([,;: !])/g, ' '))
379
- }
380
- if (name === 'mathbf' || name === 'boldsymbol' || name === 'bm' || name === 'textbf') {
381
- return h('strong', { className: 'kd-bold', key: nextKey('bd') }, latexNodes(readGroup(cur)))
382
- }
383
- if (name === 'mathbb' || name === 'mathcal' || name === 'mathscr') {
384
- const raw = readGroup(cur).trim()
385
- return h('span', { className: 'kd-blackboard', key: nextKey('bb') }, DOUBLE_STRUCK[raw] || raw)
386
- }
387
- if (name === 'overline' || name === 'bar' || name === 'underline' || name === 'vec'
388
- || name === 'hat' || name === 'widehat' || name === 'tilde' || name === 'dot' || name === 'ddot') {
389
- const body = readArg(cur)
390
- return h('span', { className: `kd-accent kd-accent-${name}`, key: nextKey('ac') }, body)
391
- }
392
- if (name === 'overrightarrow' || name === 'overleftarrow') {
393
- return h('span', { className: `kd-accent kd-accent-${name}`, key: nextKey('ac') }, readArg(cur))
394
- }
395
- // \underbrace{x}_{说明} / \overbrace{x}^{说明}:花括号画不出来,保留内容与标记
396
- if (name === 'underbrace' || name === 'overbrace') {
397
- return h('span', { className: 'kd-group', key: nextKey('grp') }, readArg(cur))
398
- }
399
- // \overset{上}{基} / \underset{下}{基}:上下堆叠
400
- if (name === 'overset' || name === 'underset') {
401
- const mark = readArg(cur)
402
- const base = readArg(cur)
403
- return h('span', {
404
- className: `kd-stack kd-stack-${name === 'overset' ? 'over' : 'under'}`,
405
- key: nextKey('st'),
406
- },
407
- name === 'overset' ? h('span', { className: 'kd-stack-mark', key: 'm' }, mark) : null,
408
- h('span', { className: 'kd-stack-base', key: 'b' }, base),
409
- name === 'underset' ? h('span', { className: 'kd-stack-mark', key: 'm' }, mark) : null)
410
- }
411
- if (name === 'left' || name === 'right' || name === 'middle') {
412
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
413
- const rest = cur.src.slice(cur.i)
414
- const command = /^\\[A-Za-z]+/.exec(rest)
415
- let token
416
- if (command) { token = command[0]; cur.i += command[0].length } else { token = rest[0] || ''; cur.i += 1 }
417
- const display = token === '.' ? '' : (LATEX_SYMBOLS[token.replace(/^\\/, '')] || token.replace(/^\\/, ''))
418
- return h('span', { className: 'kd-delim', key: nextKey('dl') }, display)
419
- }
420
- if (name === 'begin') {
421
- const env = readGroup(cur).trim()
422
- return readMatrix(cur, env)
423
- }
424
- if (name === 'displaystyle' || name === 'textstyle' || name === 'limits' || name === 'nolimits' || name === 'notag'
425
- || name === 'nonumber' || name === 'label' || name === 'tag') {
426
- // \tag{...} 之类的排版修饰我们不实现,但也不能把内容原样吐到公式里
427
- if (name === 'tag' || name === 'label') readGroup(cur)
428
- return null
429
- }
430
- // \big( \Big[ \bigl\{ … 只是放大定界符,实际字符由后面的记号自己渲染
431
- if (/^(?:big|Big|bigg|Bigg)[lrm]?$/.test(name)) return null
432
- if (name === 'bmod') return h('span', { className: 'kd-roman', key: nextKey('bm') }, 'mod')
433
- if (name === 'pmod') {
434
- return h('span', { className: 'kd-roman', key: nextKey('pm') }, '(', 'mod ',
435
- ...readArg(cur), ')')
181
+ /**
182
+ * 规范化模型输出的 TeX:
183
+ * - 去掉自带的 $ / $$ 包裹;
184
+ * - 过滤能改页面结构或注入链接的宏(公式来自模型输出,不当作可信内容);
185
+ * - 公式块里的裸 \\ 换行在 MathJax 3 需要放进 \displaylines(Typora 也是这么处理的);
186
+ * - 其余交给 MathJax,不再自己做子集解析。
187
+ */
188
+ const RISKY_TEX = [
189
+ /\\href\s*\{[^{}]*\}/g,
190
+ /\\url\s*\{[^{}]*\}/g,
191
+ /\\(?:style|class|cssId|htmlId|htmlClass|htmlStyle)\s*\{[^{}]*\}/g,
192
+ /\\require\s*\{[^{}]*\}/g,
193
+ /\\includegraphics\s*(?:\[[^\]]*\])?\s*\{[^{}]*\}/g,
194
+ ]
195
+
196
+ function normalizeTeX(source, display) {
197
+ let tex = String(source || '').trim()
198
+ tex = tex.replace(/^\${1,2}/, '').replace(/\${1,2}$/, '')
199
+ for (const pattern of RISKY_TEX) tex = tex.replace(pattern, '')
200
+ if (display && /\\\\/.test(tex) && !/\\displaylines/.test(tex) && !/\\begin\{/.test(tex)) {
201
+ tex = `\\displaylines{${tex}}`
436
202
  }
437
- // \hline 只用于表格横线;矩阵里没有对应结构,直接忽略而不是吐字面量
438
- if (name === 'hline') return null
439
- if (name === 'space' || name === 'newline') return h('br', { key: nextKey('br') })
440
- if (LATEX_FUNCS.has(name)) return h('span', { className: 'kd-roman', key: nextKey('fn') }, name)
441
- if (LATEX_SYMBOLS[name] !== undefined) return LATEX_SYMBOLS[name]
442
- // 未知命令:保留原始内容并提示,绝不丢弃。
443
- return h('span', { className: 'kd-latex-unknown', title: `未知的 LaTeX 命令:\\${name}`, key: nextKey('un') }, `\\${name}`)
203
+ return tex
444
204
  }
445
205
 
446
206
  function renderMath(source, display) {
447
- let nodes
207
+ const tex = normalizeTeX(source, display)
208
+ const key = nextKey('math')
209
+ if (mathState.status !== 'ready') {
210
+ // 引擎还没就绪(或加载失败):先显示原文,就绪后组件会重渲染
211
+ return h('span', { className: 'kd-math-pending', key, title: '公式引擎正在加载' }, tex)
212
+ }
448
213
  try {
449
- nodes = latexNodes(source)
214
+ const node = window.MathJax.tex2svg(tex, { display: Boolean(display) })
215
+ const html = node && node.outerHTML
216
+ if (!html) throw new Error('empty svg')
217
+ return h(display ? 'div' : 'span', {
218
+ className: display ? 'kd-mathblock' : 'kd-math',
219
+ key,
220
+ dangerouslySetInnerHTML: { __html: html },
221
+ })
450
222
  } catch (error) {
451
- nodes = [source]
223
+ return h('span', { className: 'kd-math-error', key, title: '公式无法渲染' }, tex)
452
224
  }
453
- return h(display ? 'div' : 'span', {
454
- className: display ? 'kd-mathblock' : 'kd-math',
455
- key: nextKey('math'),
456
- }, nodes)
457
225
  }
458
226
 
459
227
  /* ============================== Markdown ============================== */
@@ -484,6 +252,10 @@ window.__ModuleLoader__.load({
484
252
  })
485
253
  }
486
254
 
255
+ /** Markdown 里有转义意义的字符:只有这些才允许把反斜杠吃掉。 */
256
+ const MD_ESCAPABLE = new Set(['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')',
257
+ '#', '+', '-', '.', '!', '>', '|', '~', '<', '$', '"', "'"])
258
+
487
259
  function inlineNodes(text) {
488
260
  const out = []
489
261
  let buffer = ''
@@ -504,8 +276,16 @@ window.__ModuleLoader__.load({
504
276
  }
505
277
  }
506
278
  if (c === '\\' && index + 1 < text.length) {
507
- buffer += text[index + 1]
508
- index += 2
279
+ const next = text[index + 1]
280
+ // 只有 Markdown 里有意义的字符才去掉反斜杠;其余原样保留,
281
+ // 免得把 \nabla、\neq 这类命令悄悄变成 "nabla"、"neq"。
282
+ if (MD_ESCAPABLE.has(next)) {
283
+ buffer += next
284
+ index += 2
285
+ continue
286
+ }
287
+ buffer += c
288
+ index += 1
509
289
  continue
510
290
  }
511
291
  if (c === '`') {
@@ -634,7 +414,16 @@ window.__ModuleLoader__.load({
634
414
  }
635
415
 
636
416
  function renderBlocks(source) {
637
- const lines = String(source || '').replace(/\r\n?/g, '\n').split('\n')
417
+ // 模型偶尔把换行写成字面量 `\n`(JSON 里多转义了一层),这里先还原成真换行,
418
+ // 否则会渲染成一串多余的字母 n,段落结构也会塌成一行。
419
+ // 用 (?!\w) 保证不会误伤 \nabla / \neq / \theta / \times 这类命令。
420
+ const normalized = String(source || '')
421
+ .replace(/\\r\\n/g, '\n')
422
+ .replace(/\\n(?!\w)/g, '\n')
423
+ .replace(/\\r(?!\w)/g, '\n')
424
+ .replace(/\\t(?!\w)/g, ' ')
425
+ .replace(/\r\n?/g, '\n')
426
+ const lines = normalized.split('\n')
638
427
  const out = []
639
428
  let index = 0
640
429
  while (index < lines.length) {
@@ -738,13 +527,15 @@ window.__ModuleLoader__.load({
738
527
  }
739
528
 
740
529
  function Markdown({ text, className }) {
530
+ // 公式引擎加载完成后要重渲染一次,否则首次绘制出来的还是 TeX 原文
531
+ const mathStatus = useMathJaxStatus()
741
532
  const nodes = useMemo(() => {
742
533
  try {
743
534
  return renderBlocks(text)
744
535
  } catch (error) {
745
536
  return [h('pre', { className: 'kd-code', key: 'raw' }, String(text || ''))]
746
537
  }
747
- }, [text])
538
+ }, [text, mathStatus])
748
539
  return h('div', { className: `kd-md ${className || ''}` }, nodes)
749
540
  }
750
541
 
@@ -752,12 +543,6 @@ window.__ModuleLoader__.load({
752
543
  /** 由 apply 用 styles.insert 注入时置位;否则由组件渲染 <style> 兜底。 */
753
544
  let styleInjected = false
754
545
  const CSS = `
755
- @font-face {
756
- font-family: 'KD Latin Modern Math';
757
- src: local('Latin Modern Math'), local('LatinModernMath'), local('LatinModernMath-Regular'),
758
- url('/api/daily-question/font/latinmodern-math.otf') format('opentype');
759
- font-display: swap;
760
- }
761
546
  .kd-page { height:100%; min-height:0; display:flex; flex-direction:column; position:relative;
762
547
  background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary);
763
548
  font-size:14px; line-height:1.65; -webkit-font-smoothing:antialiased; box-sizing:border-box; }
@@ -1022,72 +807,15 @@ window.__ModuleLoader__.load({
1022
807
  .kd-image-fallback { display:inline-block; font-family:ui-monospace,monospace; font-size:12px;
1023
808
  color:var(--dsw-alias-state-warn-primary); border:1px dashed var(--dsw-alias-state-warn-primary);
1024
809
  border-radius:6px; padding:2px 6px; }
1025
- .kd-math { display:inline-block; vertical-align:middle; }
1026
- .kd-mathblock { display:block; margin:10px 0; overflow-x:auto; overflow-y:hidden; text-align:center; }
1027
- .kd-math, .kd-mathblock {
1028
- font-family: 'KD Latin Modern Math', 'Latin Modern Math', 'Cambria Math', 'STIX Two Math',
1029
- 'TeX Gyre Termes Math', 'Times New Roman', serif;
1030
- font-feature-settings: 'ss01' 0;
1031
- letter-spacing: 0;
1032
- }
1033
- /* 分式:整块按“垂直中线”对齐到父级数学轴线(约基线上方 .25em),
1034
- 而不是下压半个字高;分子分母固定行高,保证分数线正好落在中线上。 */
1035
- .kd-frac { display:inline-flex; flex-direction:column; align-items:stretch; vertical-align:middle;
1036
- text-align:center; margin:0 .18em; line-height:1.15; }
1037
- .kd-frac-n { padding:0 .25em .03em; }
1038
- .kd-frac-d { border-top:1px solid currentColor; padding:.03em .25em 0; }
1039
- /* boxed:把最终答案框起来 */
1040
- .kd-boxed { display:inline-flex; align-items:center; padding:.06em .38em; margin:0 .12em;
1041
- border:1px solid currentColor; border-radius:3px; }
1042
- /* overset / underset:在基座上方或下方叠一行小字 */
1043
- .kd-stack { display:inline-flex; flex-direction:column; align-items:center; vertical-align:middle;
1044
- line-height:1.1; }
1045
- .kd-stack-mark { font-size:.72em; }
1046
- .kd-stack-base { line-height:1.2; }
1047
- /* underbrace / overbrace 的花括号暂不绘制,只保留内容分组 */
1048
- .kd-group { display:inline; }
1049
- /* binom:没有分数线的上下堆叠 */
1050
- .kd-binom { display:inline-flex; align-items:center; vertical-align:middle; margin:0 .1em; }
1051
- .kd-binom .kd-frac-d { border-top:0; padding-top:0; }
1052
- .kd-sup, .kd-sub { font-size:.72em; }
1053
- .kd-sup { vertical-align:super; }
1054
- .kd-sub { vertical-align:sub; }
1055
- .kd-sqrt { display:inline-flex; align-items:flex-start; }
1056
- .kd-sqrt-sign { font-size:1.05em; }
1057
- .kd-sqrt-index { font-size:.6em; margin-left:-.35em; }
1058
- .kd-sqrt-body { border-top:1px solid currentColor; padding:0 .12em; }
1059
- .kd-op { display:inline-flex; flex-direction:column; align-items:center; vertical-align:middle;
1060
- margin:0 .2em; line-height:1.15; }
1061
- .kd-op-sym { font-size:1.35em; line-height:1; }
1062
- .kd-op-lim { font-size:.62em; }
1063
- .kd-roman { font-style:normal; }
1064
- .kd-bold { font-weight:700; font-style:normal; }
1065
- .kd-blackboard { font-style:normal; }
1066
- .kd-colsep { display:inline-block; width:.5em; }
1067
- .kd-matrixbox { display:inline-flex; align-items:center; vertical-align:middle; }
1068
- .kd-matrix { border-collapse:collapse; }
1069
- .kd-matrix td { padding:2px 7px; text-align:center; border:0; }
1070
- .kd-delim { font-size:1.5em; line-height:1; }
1071
- .kd-cases .kd-matrix td { text-align:left; }
1072
- .kd-accent-overline, .kd-accent-bar { border-top:1px solid currentColor; }
1073
- .kd-accent-underline { border-bottom:1px solid currentColor; }
1074
- .kd-accent-vec { position:relative; }
1075
- .kd-accent-vec::before { content:'→'; position:absolute; top:-.75em; left:50%; transform:translateX(-50%); font-size:.7em; }
1076
- .kd-accent-overrightarrow { position:relative; }
1077
- .kd-accent-overrightarrow::before { content:'→'; position:absolute; top:-.85em; left:0; right:0;
1078
- text-align:center; font-size:.72em; line-height:1; }
1079
- .kd-accent-overleftarrow { position:relative; }
1080
- .kd-accent-overleftarrow::before { content:'←'; position:absolute; top:-.85em; left:0; right:0;
1081
- text-align:center; font-size:.72em; line-height:1; }
1082
- .kd-accent-hat::before, .kd-accent-widehat::before { content:'^'; position:absolute; top:-.5em; left:50%; transform:translateX(-50%); font-size:.8em; }
1083
- .kd-accent-hat, .kd-accent-widehat { position:relative; }
1084
- .kd-accent-tilde { position:relative; }
1085
- .kd-accent-tilde::before { content:'~'; position:absolute; top:-.55em; left:50%; transform:translateX(-50%); }
1086
- .kd-accent-dot { position:relative; }
1087
- .kd-accent-dot::before { content:'·'; position:absolute; top:-.6em; left:50%; transform:translateX(-50%); }
1088
- .kd-accent-ddot { position:relative; }
1089
- .kd-accent-ddot::before { content:'··'; position:absolute; top:-.6em; left:50%; transform:translateX(-50%); }
1090
- .kd-latex-unknown { color:var(--dsw-alias-state-warn-primary); font-size:.92em; }
810
+ .kd-math { display:inline-block; vertical-align:middle; max-width:100%; }
811
+ .kd-math mjx-container { color:currentColor; }
812
+ .kd-mathblock { display:block; margin:10px 0; padding:.35em .2em; overflow-x:auto; overflow-y:hidden;
813
+ text-align:center; }
814
+ .kd-mathblock mjx-container { display:inline-block; max-width:100%; }
815
+ .kd-math-pending { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:.9em;
816
+ opacity:.55; white-space:pre-wrap; }
817
+ .kd-math-error { color:var(--dsw-alias-state-error-primary);
818
+ font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:.9em; }
1091
819
  .kd-checkline { display:flex; gap:8px; align-items:flex-start; padding:5px 0; font-size:13px; }
1092
820
  .kd-chip { display:inline-flex; align-items:center; gap:2px; padding:0 2px 0 8px; border-radius:999px;
1093
821
  border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1); font-size:12px; }