fix(exam-prep): репаратор рендеринга ctmath — потерянные \ в опциях + <,> в $…$
Root cause: в seed-ах вариантов 101–121 опции писались как mc('$\sqrt..$') в
обычных кавычках вместо R`…` → JS-парсер съедал \s→s, \d→d и т.п., в БД легло
«$sqrt{17}$», «$dfrac{pi}{3}$» (KaTeX рисует «sqrt17», «dfracpi3»). Плюс литеральные
<,> внутри $…$ ломали HTML до KaTeX. Скрипт fix_ctmath_render.js (dry-run/--apply,
идемпотентный): восстанавливает \ перед командами (+нормализует управляющие символы)
в opts_json и заменяет <,>→\lt,\gt внутри $…$ в text/opts/solution. DRY-RUN: 307
строк (143 opts/128 text/157 sol), остаточных багов 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
fix_ctmath_render.js — починка двух дефектов рендеринга в exam_tasks (ctmath).
|
||||
|
||||
ПРИЧИНА (root cause):
|
||||
В seed-скриптах вариантов 101–121 опции писались как mc('$\sqrt{17}$', ...) —
|
||||
в ОБЫЧНЫХ кавычках, а не в String.raw `…`. JS-парсер съедал управляющие
|
||||
эскейпы: \s→s, \d→d (теряется «\»), а \f→0x0C, \t→0x09, \b→0x08, \v,\n,\r —
|
||||
превращались в УПРАВЛЯЮЩИЕ символы. Итог в БД: «$sqrt{17}$», «$dfrac{pi}{3}$»,
|
||||
KaTeX рендерит их как «sqrt17», «dfracpi3». (text/solution писались через R`…`
|
||||
и НЕ пострадали — там «\» на месте.)
|
||||
|
||||
ВТОРОЙ ДЕФЕКТ: литеральные < и > ВНУТРИ $…$ (напр. «$-1{,}6<x<-1$»). При вставке
|
||||
в innerHTML браузер парсит «<x…» как HTML-тег ДО запуска KaTeX → ломает карточку.
|
||||
Лечится заменой < → \lt, > → \gt (только внутри $…$).
|
||||
|
||||
ЧТО ДЕЛАЕТ СКРИПТ (идемпотентно, повторный запуск безопасен):
|
||||
• opts_json: (1) нормализует управляющие символы обратно в \f \t \b \v \n \r;
|
||||
(2) восстанавливает «\» перед известными KaTeX-командами; (3) < > → \lt \gt.
|
||||
• text_html, solution_html: только (3) < > → \lt \gt внутри $…$ (HTML-теги вне
|
||||
математики не трогаются).
|
||||
Восстановление «\» применяется ТОЛЬКО к opts_json (text/sol не повреждены).
|
||||
|
||||
Запуск:
|
||||
node backend/scripts/fix_ctmath_render.js # DRY-RUN (показывает правки)
|
||||
node backend/scripts/fix_ctmath_render.js --apply # запись в БД
|
||||
⚠️ Запись запускает ПОЛЬЗОВАТЕЛЬ. После --apply — перезапуск сервера не нужен
|
||||
(данные в БД; фронт перечитает их при следующем запросе), но hard-refresh браузера.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const EXAM = 'ctmath';
|
||||
|
||||
/* ── Управляющие символы → их LaTeX-эскейп (обратная нормализация) ── */
|
||||
const CTRL_MAP = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\v': '\\v', '\f': '\\f', '\r': '\\r' };
|
||||
function normalizeCtrl(s) {
|
||||
return s.replace(/[\b\t\n\v\f\r]/g, ch => CTRL_MAP[ch] || ch);
|
||||
}
|
||||
|
||||
/* ── Команды, у которых первая буква НЕ из {f,t,b,v,n,r} (их «\» просто пропал,
|
||||
без управляющего символа). Длинные/составные — РАНЬШЕ коротких префиксов,
|
||||
чтобы не разорвать слово (dfrac до frac, arccos до cos, leq до le, …). ── */
|
||||
const BARE_CMDS = [
|
||||
'arccos', 'arcsin', 'arctg',
|
||||
'overline', 'operatorname', 'varnothing', 'varphi', 'varepsilon',
|
||||
'dfrac', 'cdots', 'cdot', 'sqrt', 'left',
|
||||
'lambda', 'gamma', 'delta', 'sigma', 'omega', 'alpha', 'angle', 'approx',
|
||||
'infty', 'ldots', 'oplus',
|
||||
'cos', 'sin', 'cot', 'ctg', 'cup', 'cap', 'leq', 'geq', 'neq',
|
||||
'sim', 'lim', 'log',
|
||||
'pm', 'mp', 'le', 'ge', 'ln', 'lg', 'pi', 'mu', 'in',
|
||||
'phi', 'psi', 'rho', 'chi', 'tau',
|
||||
];
|
||||
/* (frac, tfrac, times, theta, tan, tg, text, beta, vec, ne, nu, right, nabla —
|
||||
приходят из управляющих символов и чинятся normalizeCtrl, поэтому в этом
|
||||
списке их НЕТ: иначе «dfrac»→«d\frac».) */
|
||||
|
||||
function restoreBackslashes(math) {
|
||||
let s = normalizeCtrl(math);
|
||||
for (const cmd of BARE_CMDS) {
|
||||
// «\bcmd», не уже-экранированное (нет «\» перед), как самостоятельное слово
|
||||
const re = new RegExp('(^|[^\\\\A-Za-z])' + cmd + '(?![A-Za-z])', 'g');
|
||||
s = s.replace(re, (m, pre) => pre + '\\' + cmd);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── < > → \lt \gt ТОЛЬКО внутри $…$ / $$…$$ ── */
|
||||
function fixAngles(field) {
|
||||
if (!field) return field;
|
||||
return String(field).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg =>
|
||||
seg.replace(/</g, '\\lt ').replace(/>/g, '\\gt '));
|
||||
}
|
||||
|
||||
/* ── Полная починка одной опции (внутри $…$): \ + < > ── */
|
||||
function fixOptionText(t) {
|
||||
if (!t) return t;
|
||||
// обрабатываем содержимое каждого $…$: восстановить «\», затем < >
|
||||
return String(t).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg => {
|
||||
const open = seg.startsWith('$$') ? '$$' : '$';
|
||||
const inner = seg.slice(open.length, seg.length - open.length);
|
||||
let fixed = restoreBackslashes(inner);
|
||||
fixed = fixed.replace(/</g, '\\lt ').replace(/>/g, '\\gt ');
|
||||
return open + fixed + open;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Открытие БД ── */
|
||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||
const db = new DatabaseSync(DB);
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT id, variant, task_idx, task_type, text_html, opts_json, solution_html
|
||||
FROM exam_tasks WHERE exam_key=? ORDER BY variant, task_idx`).all(EXAM);
|
||||
|
||||
let changedRows = 0, changedOpts = 0, changedText = 0, changedSol = 0;
|
||||
const samples = [];
|
||||
|
||||
const upd = db.prepare(
|
||||
`UPDATE exam_tasks SET text_html=?, opts_json=?, solution_html=? WHERE id=?`);
|
||||
|
||||
if (APPLY) db.exec('BEGIN');
|
||||
try {
|
||||
for (const r of rows) {
|
||||
let newOpts = r.opts_json;
|
||||
let newText = r.text_html;
|
||||
let newSol = r.solution_html;
|
||||
let touched = false;
|
||||
|
||||
// opts_json — восстановление «\» + < >
|
||||
if (r.opts_json) {
|
||||
try {
|
||||
const arr = JSON.parse(r.opts_json);
|
||||
const fixed = arr.map(([l, t]) => [l, fixOptionText(t)]);
|
||||
const cand = JSON.stringify(fixed);
|
||||
if (cand !== r.opts_json) { newOpts = cand; changedOpts++; touched = true; }
|
||||
} catch { /* не-JSON — пропускаем */ }
|
||||
}
|
||||
// text_html / solution_html — только < >
|
||||
const ft = fixAngles(r.text_html);
|
||||
if (ft !== r.text_html) { newText = ft; changedText++; touched = true; }
|
||||
const fs = fixAngles(r.solution_html);
|
||||
if (fs !== r.solution_html) { newSol = fs; changedSol++; touched = true; }
|
||||
|
||||
if (touched) {
|
||||
changedRows++;
|
||||
if (samples.length < 12) {
|
||||
samples.push({ v: r.variant, i: r.task_idx,
|
||||
beforeOpts: r.opts_json && r.opts_json.length > 90 ? r.opts_json.slice(0, 90) + '…' : r.opts_json,
|
||||
afterOpts: newOpts && newOpts.length > 90 ? newOpts.slice(0, 90) + '…' : newOpts });
|
||||
}
|
||||
if (APPLY) upd.run(newText, newOpts, newSol, r.id);
|
||||
}
|
||||
}
|
||||
if (APPLY) db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
if (APPLY) db.exec('ROLLBACK');
|
||||
console.error('✗ Ошибка, откат:', e.message);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n=== fix_ctmath_render (${APPLY ? 'APPLY' : 'DRY-RUN'}) ===`);
|
||||
console.log(`Всего задач ctmath: ${rows.length}`);
|
||||
console.log(`Будет изменено строк: ${changedRows} (opts: ${changedOpts}, text: ${changedText}, sol: ${changedSol})`);
|
||||
console.log(`\nПримеры (opts до → после):`);
|
||||
for (const s of samples) {
|
||||
console.log(`\n v${s.v}#${s.i}`);
|
||||
console.log(` ДО: ${s.beforeOpts}`);
|
||||
console.log(` ПОСЛЕ: ${s.afterOpts}`);
|
||||
}
|
||||
|
||||
/* контроль остаточных «голых» команд после починки (для self-check в dry-run) */
|
||||
if (!APPLY) {
|
||||
const after = db.prepare(`SELECT opts_json FROM exam_tasks WHERE exam_key=? AND opts_json IS NOT NULL`).all(EXAM);
|
||||
let leftover = 0;
|
||||
for (const r of after) {
|
||||
let arr; try { arr = JSON.parse(r.opts_json); } catch { continue; }
|
||||
for (const [, t] of arr) {
|
||||
const fixedNow = fixOptionText(t);
|
||||
// ищем подозрительные «\bdfrac/sqrt/frac…» БЕЗ слэша уже ПОСЛЕ починки
|
||||
if (/(^|[^\\A-Za-z])(sqrt|dfrac|frac|tfrac|cdot|times|alpha|beta|theta|pi)(?![A-Za-z])/.test(fixedNow.replace(/\$/g,''))) leftover++;
|
||||
}
|
||||
}
|
||||
console.log(`\nКонтроль: потенциально не починенных опций после прогона: ${leftover}`);
|
||||
console.log(`\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/fix_ctmath_render.js --apply\n`);
|
||||
}
|
||||
db.close();
|
||||
Reference in New Issue
Block a user