Files
Learn_System/backend/scripts/fix_ctmath_render.js
T
Maxim Dolgolyov bd7dd06e47 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>
2026-06-20 19:34:59 +03:00

172 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();