'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();