v4.1.5: KaTeX 수식·화학식 렌더링 + SMILES 2D 구조식(결합선) 추가 + 코드블록 이스케이핑 버그 수정

- KaTeX self-host (vendor/katex) 추가, $...$/$$...$$ 자동 렌더링 + mhchem 확장으로
  \ce{C4H10} 같은 화학식·반응식 표기 지원 (renderKatexIn, renderChatMessages 후처리)
- SmilesDrawer self-host (vendor/smiles-drawer) 추가, ```smiles``` 코드블록으로 SMILES
  표기를 실제 결합선 있는 2D 구조식으로 렌더링. 라이브러리의 Drawer(canvas) 클래스가
  내부 svgWrapper null 참조로 깨지는 버그가 있어 SvgDrawer로 우회
- 진짜 원인 버그 수정: escHtml()가 백틱(`)/백슬래시/달러도 이스케이프하는데, 코드블록·
  인라인코드 정규식이 이스케이프 *이후*에 원본 백틱을 찾고 있어 애초에 매치가 안 됐음
  — tradingview 차트·네이버 주식차트·MIDI 위젯 등 기존 기능도 전부 이 버그로 작동 안
  하고 있었고, 이번에 같이 고쳐서 정상 작동 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-14 12:35:33 +09:00
co-authored by Claude Sonnet 5
parent 4526de2914
commit c8b4e251bf
68 changed files with 168 additions and 5 deletions
+87 -4
View File
@@ -1076,6 +1076,26 @@ async function openInFileLocation(targetPath) {
}
}
// KaTeX auto-render must run AFTER the HTML is in the DOM — innerHTML doesn't execute
// embedded <script>, so this has to be called explicitly wherever chat HTML gets inserted
// (mirrors validateDownloadLinks() right above each call site). mhchem.min.js (loaded in
// index.html alongside katex.min.js) extends KaTeX's math mode with \ce{...} for reaction/
// formula notation, e.g. $\ce{C4H10}$ or $\ce{2H2 + O2 -> 2H2O}$.
function renderKatexIn(container) {
if (!window.renderMathInElement || !container) return;
try {
renderMathInElement(container, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
],
throwOnError: false,
});
} catch (e) {}
}
function renderChatMessages() {
const container = document.getElementById('chat-messages');
@@ -1115,6 +1135,7 @@ function renderChatMessages() {
}).join('');
validateDownloadLinks(container);
renderKatexIn(container);
if (isThinking && streamingSessionId === activeChatSessionId) {
const progressHtml = currentProgressLines.length
@@ -6244,8 +6265,11 @@ function renderMarkdown(text, opts) {
html = html.replace('\x00IMG' + i + '\x00', img);
});
// Code blocks (``` ... ```)
html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) {
// Code blocks (``` ... ```) — escHtml() above already turned literal backticks into
// &#96; (it escapes backtick/backslash/$ too, not just &<>"'), so matching on literal
// backticks here never matched anything — every special widget block (midi-widget, abc,
// lilypond, tradingview, kstock, smiles) silently fell through as escaped plain text.
html = html.replace(/&#96;&#96;&#96;([\w-]*)\n([\s\S]*?)&#96;&#96;&#96;/g, function(_, lang, code) {
var trimmed = code.trim();
if (lang === 'midi-widget') {
try {
@@ -6332,11 +6356,35 @@ function renderMarkdown(text, opts) {
'</div>' +
'</div>';
}
if (lang === 'smiles') {
// Also undo escHtml()'s backslash/backtick/$ escaping (not just the amp/lt/gt/quote
// set the other widget blocks unescape) — SMILES cis/trans bond notation uses
// backslash (e.g. C/C=C/C), which would otherwise stay mangled as &#92;.
var smRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'").replace(/&#96;/g,'`').replace(/&#36;/g,'$').replace(/&#92;/g,'\\').trim();
if (!smRaw) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var smId = 'sm-' + Math.random().toString(36).slice(2, 9);
var smEncoded = encodeURIComponent(smRaw);
return '<div class="music-widget" id="' + smId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">⚗️ ' + escHtml(smRaw) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderSmilesStructure(this,\'' + smId + '-c\',decodeURIComponent(\'' + smEncoded + '\'))">구조식 그리기</button>' +
'<button onclick="toggleMusicWidget(\'' + smId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + smId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body" style="padding:8px;text-align:center">' +
'<svg id="' + smId + '-c" width="400" height="300" style="max-width:100%"></svg>' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
// Inline code — same escHtml()-escapes-backticks issue as the fenced code blocks above.
// Content may still contain other entities (&amp;, &lt;, ...), so only exclude another
// &#96; sequence rather than excluding every '&' (which would truncate on those entities).
html = html.replace(/&#96;((?:(?!&#96;)[\s\S])+?)&#96;/g, '<code class="md-inline-code">$1</code>');
// Images: ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
@@ -6492,6 +6540,41 @@ function renderTVChart(btn, containerId, symbol, interval) {
}
}
// SMILES → 2D 화학 구조식 (결합선 포함). SmilesDrawer는 로컬 캔버스 렌더링이라
// 네트워크 지연 없이 즉시 그려지지만, innerHTML로 삽입된 <canvas>는 아직 DOM에
// 붙기 전이라 위젯 코드 블록에서 바로 그릴 수 없음 — 버튼 클릭으로 트리거.
function renderSmilesStructure(btn, svgId, smiles) {
btn.disabled = true;
btn.textContent = '그리는 중...';
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
if (!window.SmilesDrawer) {
btn.disabled = false;
btn.textContent = '⚠️ 라이브러리 로드 실패';
return;
}
try {
SmilesDrawer.parse(smiles, function(tree) {
try {
// SmilesDrawer.Drawer (canvas) throws internally in this build ("this.svgWrapper
// is null" — a bug in the npm package itself, reproduced directly against the
// unminified call). SvgDrawer works correctly, so render to <svg> instead.
var drawer = new SmilesDrawer.SvgDrawer({ width: 400, height: 300 });
drawer.draw(tree, svgId, isDark ? 'dark' : 'light');
btn.style.display = 'none';
} catch (e) {
btn.disabled = false;
btn.textContent = '⚠️ 렌더링 실패';
}
}, function() {
btn.disabled = false;
btn.textContent = '⚠️ SMILES 파싱 실패';
});
} catch (e) {
btn.disabled = false;
btn.textContent = '⚠️ 렌더링 실패';
}
}
function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId);
if (!widget) return;
+5
View File
@@ -21,6 +21,11 @@
<script src="/vendor/xterm.js"></script>
<script src="/vendor/addon-fit.js"></script>
<script src="/vendor/addon-web-links.js"></script>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="/vendor/smiles-drawer/smiles-drawer.min.js"></script>
<link rel="stylesheet" href="styles.css">
<script src="/vendor/tone-midi.js"></script>
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={771:function(t){t.exports=e}},r={};function n(e){var i=r[e];if(void 0!==i)return i.exports;var a=r[e]={exports:{}};return t[e](a,a.exports,n),a.exports}n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,{a:t}),t},n.d=function(e,t){for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var i={};return function(){n.d(i,{default:function(){return s}});var e=n(771),t=n.n(e),r=function(e,t,r){for(var n=r,i=0,a=e.length;n<t.length;){var o=t[n];if(i<=0&&t.slice(n,n+a)===e)return n;"\\"===o?n++:"{"===o?i++:"}"===o&&i--,n++}return-1},a=/^\\begin{/,o=function(e,t){for(var n,i=[],o=new RegExp("("+t.map((function(e){return e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&")})).join("|")+")");-1!==(n=e.search(o));){n>0&&(i.push({type:"text",data:e.slice(0,n)}),e=e.slice(n));var l=t.findIndex((function(t){return e.startsWith(t.left)}));if(-1===(n=r(t[l].right,e,t[l].left.length)))break;var d=e.slice(0,n+t[l].right.length),s=a.test(d)?d:e.slice(t[l].left.length,n);i.push({type:"math",data:s,rawData:d,display:t[l].display}),e=e.slice(n+t[l].right.length)}return""!==e&&i.push({type:"text",data:e}),i},l=function(e,r){var n=o(e,r.delimiters);if(1===n.length&&"text"===n[0].type)return null;for(var i=document.createDocumentFragment(),a=0;a<n.length;a++)if("text"===n[a].type)i.appendChild(document.createTextNode(n[a].data));else{var l=document.createElement("span"),d=n[a].data;r.displayMode=n[a].display;try{r.preProcess&&(d=r.preProcess(d)),t().render(d,l,r)}catch(e){if(!(e instanceof t().ParseError))throw e;r.errorCallback("KaTeX auto-render: Failed to parse `"+n[a].data+"` with ",e),i.appendChild(document.createTextNode(n[a].rawData));continue}i.appendChild(l)}return i},d=function e(t,r){for(var n=0;n<t.childNodes.length;n++){var i=t.childNodes[n];if(3===i.nodeType){for(var a=i.textContent,o=i.nextSibling,d=0;o&&o.nodeType===Node.TEXT_NODE;)a+=o.textContent,o=o.nextSibling,d++;var s=l(a,r);if(s){for(var f=0;f<d;f++)i.nextSibling.remove();n+=s.childNodes.length-1,t.replaceChild(s,i)}else n+=d}else 1===i.nodeType&&function(){var t=" "+i.className+" ";-1===r.ignoredTags.indexOf(i.nodeName.toLowerCase())&&r.ignoredClasses.every((function(e){return-1===t.indexOf(" "+e+" ")}))&&e(i,r)}()}},s=function(e,t){if(!e)throw new Error("No element provided to render");var r={};for(var n in t)t.hasOwnProperty(n)&&(r[n]=t[n]);r.delimiters=r.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],r.ignoredTags=r.ignoredTags||["script","noscript","style","textarea","pre","code","option"],r.ignoredClasses=r.ignoredClasses||[],r.errorCallback=r.errorCallback||console.error,r.macros=r.macros||{},d(e,r)}}(),i=i.default}()}));
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long