diff --git a/backend/scripts/fix_ctmath_render.js b/backend/scripts/fix_ctmath_render.js new file mode 100644 index 0000000..f5774f9 --- /dev/null +++ b/backend/scripts/fix_ctmath_render.js @@ -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 → \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, '\\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, '\\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();