リポジトリ / emssr / independent_common_compiler
r2
| r2 emssr | 1 | // 説明セルの描画。Markdown は marked、数式は KaTeX が描く。 |
| r2 emssr | 2 | // |
| r2 emssr | 3 | // Markdown 見出し、表、箇条書き、引用、code、画像、動画、素の HTML |
| r2 emssr | 4 | // 数式 $…$ と $$…$$、\(…\) と \[…\] |
| r2 emssr | 5 | // 画像と動画 assets/ へ置いたものを  や <video> で貼る |
| r2 emssr | 6 | // |
| r2 emssr | 7 | // 素の HTML を通すので、描く前に許可した tag と属性だけへ落とす。 |
| r2 emssr | 8 | // 他人の notebook を `.json` で開けるため、この関門は外せない。 |
| r2 emssr | 9 | |
| r2 emssr | 10 | const Render = (function () { |
| r2 emssr | 11 | |
| r2 emssr | 12 | const RENDER = { ready: false, base: null }; |
| r2 emssr | 13 | |
| r2 emssr | 14 | function loadCss(href) { |
| r2 emssr | 15 | const l = document.createElement('link'); |
| r2 emssr | 16 | l.rel = 'stylesheet'; |
| r2 emssr | 17 | l.href = href; |
| r2 emssr | 18 | document.head.appendChild(l); |
| r2 emssr | 19 | } |
| r2 emssr | 20 | |
| r2 emssr | 21 | function loadJs(src) { |
| r2 emssr | 22 | return new Promise((ok, no) => { |
| r2 emssr | 23 | const s = document.createElement('script'); |
| r2 emssr | 24 | s.src = src; s.onload = ok; s.onerror = () => no(new Error(src)); |
| r2 emssr | 25 | document.head.appendChild(s); |
| r2 emssr | 26 | }); |
| r2 emssr | 27 | } |
| r2 emssr | 28 | |
| r2 emssr | 29 | // base は /vendor/web/ か CDN。setup.py が置いていれば手元のコピーを使う |
| r2 emssr | 30 | async function startRender(base) { |
| r2 emssr | 31 | RENDER.base = base; |
| r2 emssr | 32 | loadCss(base + 'katex.min.css'); |
| r2 emssr | 33 | await loadJs(base + 'marked.umd.js'); |
| r2 emssr | 34 | await loadJs(base + 'katex.min.js'); |
| r2 emssr | 35 | marked.setOptions({ gfm: true, breaks: false }); |
| r2 emssr | 36 | RENDER.ready = true; |
| r2 emssr | 37 | } |
| r2 emssr | 38 | |
| r2 emssr | 39 | // ===== 関門 ===== |
| r2 emssr | 40 | |
| r2 emssr | 41 | const TAGS = new Set(( |
| r2 emssr | 42 | 'p br hr h1 h2 h3 h4 h5 h6 strong b em i del s u code pre blockquote ' + |
| r2 emssr | 43 | 'ul ol li dl dt dd table thead tbody tfoot tr th td caption ' + |
| r2 emssr | 44 | 'a img video audio source track figure figcaption ' + |
| r2 emssr | 45 | 'span div sup sub kbd mark small abbr details summary ' + |
| r2 emssr | 46 | 'svg g path circle ellipse rect line polyline polygon text tspan defs marker' |
| r2 emssr | 47 | ).split(' ')); |
| r2 emssr | 48 | |
| r2 emssr | 49 | const ATTRS = { |
| r2 emssr | 50 | a: ['href', 'title', 'target', 'rel'], |
| r2 emssr | 51 | img: ['src', 'alt', 'title', 'width', 'height', 'loading'], |
| r2 emssr | 52 | video: ['src', 'controls', 'width', 'height', 'poster', 'loop', 'muted', |
| r2 emssr | 53 | 'playsinline', 'preload', 'autoplay'], |
| r2 emssr | 54 | audio: ['src', 'controls', 'preload', 'loop'], |
| r2 emssr | 55 | source: ['src', 'type'], |
| r2 emssr | 56 | track: ['src', 'kind', 'srclang', 'label', 'default'], |
| r2 emssr | 57 | th: ['colspan', 'rowspan', 'align'], |
| r2 emssr | 58 | td: ['colspan', 'rowspan', 'align'], |
| r2 emssr | 59 | ol: ['start', 'type'], |
| r2 emssr | 60 | details: ['open'], |
| r2 emssr | 61 | svg: ['viewbox', 'width', 'height', 'xmlns', 'preserveaspectratio'], |
| r2 emssr | 62 | }; |
| r2 emssr | 63 | // 図形は形の欄だけ通す |
| r2 emssr | 64 | const SHAPE = ['d', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', |
| r2 emssr | 65 | 'width', 'height', 'points', 'fill', 'stroke', 'stroke-width', |
| r2 emssr | 66 | 'stroke-dasharray', 'stroke-linecap', 'opacity', 'transform', |
| r2 emssr | 67 | 'text-anchor', 'font-size', 'font-family', 'dominant-baseline']; |
| r2 emssr | 68 | const COMMON_ATTRS = ['class', 'align', 'style']; |
| r2 emssr | 69 | |
| r2 emssr | 70 | const URL_OK = /^(https?:|mailto:|assets\/|\.\/|\/(?!\/)|#|data:image\/(png|jpeg|gif|webp|svg\+xml);base64,)/i; |
| r2 emssr | 71 | // style は文字揃えだけ許す。表の列揃えに marked が使う |
| r2 emssr | 72 | const STYLE_OK = /^text-align:\s*(left|right|center)\s*;?$/i; |
| r2 emssr | 73 | |
| r2 emssr | 74 | function sanitize(node) { |
| r2 emssr | 75 | for (const child of [...node.children]) { |
| r2 emssr | 76 | const tag = child.tagName.toLowerCase(); |
| r2 emssr | 77 | if (!TAGS.has(tag)) { |
| r2 emssr | 78 | child.replaceWith(...child.childNodes); |
| r2 emssr | 79 | continue; |
| r2 emssr | 80 | } |
| r2 emssr | 81 | const allow = new Set([...(ATTRS[tag] || []), ...COMMON_ATTRS]); |
| r2 emssr | 82 | if (tag === 'svg' || child.closest('svg')) SHAPE.forEach(a => allow.add(a)); |
| r2 emssr | 83 | for (const at of [...child.attributes]) { |
| r2 emssr | 84 | const name = at.name.toLowerCase(); |
| r2 emssr | 85 | if (!allow.has(name)) { child.removeAttribute(at.name); continue; } |
| r2 emssr | 86 | if (name === 'style' && !STYLE_OK.test(at.value)) child.removeAttribute(at.name); |
| r2 emssr | 87 | if ((name === 'src' || name === 'href') && !URL_OK.test(at.value.trim())) { |
| r2 emssr | 88 | child.removeAttribute(at.name); |
| r2 emssr | 89 | } |
| r2 emssr | 90 | } |
| r2 emssr | 91 | sanitize(child); |
| r2 emssr | 92 | } |
| r2 emssr | 93 | } |
| r2 emssr | 94 | |
| r2 emssr | 95 | // ===== 数式 ===== |
| r2 emssr | 96 | // |
| r2 emssr | 97 | // Markdown より先に数式を抜く。marked は行末の逆斜線二つを「escape した逆斜線」として |
| r2 emssr | 98 | // 畳むので、後から当てると aligned の改行が消える。抜いた跡へ印を置き、Markdown の |
| r2 emssr | 99 | // 後で戻す。 |
| r2 emssr | 100 | // |
| r2 emssr | 101 | // $$ … $$ \[ … \] 別行 |
| r2 emssr | 102 | // $ … $ \( … \) 行の中。開いた直後と閉じる直前に空白を置かない |
| r2 emssr | 103 | // |
| r2 emssr | 104 | // ` と ``` の中は数式として読まない。 |
| r2 emssr | 105 | |
| r2 emssr | 106 | // 印は私用領域の二文字で挟む。CommonMark は U+0000 を U+FFFD へ置き換える決まりなので、 |
| r2 emssr | 107 | // NUL を印に使うと Markdown を通った時点で消える |
| r2 emssr | 108 | const OPEN = String.fromCharCode(0xE000); |
| r2 emssr | 109 | const SHUT = String.fromCharCode(0xE001); |
| r2 emssr | 110 | const BS = '\\'; |
| r2 emssr | 111 | const BLANK = /\n\s*\n/; |
| r2 emssr | 112 | const AFTER_OPEN = /[\s$]/; |
| r2 emssr | 113 | |
| r2 emssr | 114 | function takeMath(src) { |
| r2 emssr | 115 | const found = []; |
| r2 emssr | 116 | let out = '', i = 0; |
| r2 emssr | 117 | const push = (tex, display) => { |
| r2 emssr | 118 | out += OPEN + found.length + SHUT; |
| r2 emssr | 119 | found.push({ tex: tex, display: display }); |
| r2 emssr | 120 | }; |
| r2 emssr | 121 | while (i < src.length) { |
| r2 emssr | 122 | // code fence と code span は素通しする |
| r2 emssr | 123 | if (src.startsWith('```', i)) { |
| r2 emssr | 124 | const end = src.indexOf('```', i + 3); |
| r2 emssr | 125 | const to = end < 0 ? src.length : end + 3; |
| r2 emssr | 126 | out += src.slice(i, to); i = to; continue; |
| r2 emssr | 127 | } |
| r2 emssr | 128 | if (src[i] === '`') { |
| r2 emssr | 129 | const end = src.indexOf('`', i + 1); |
| r2 emssr | 130 | const to = end < 0 ? src.length : end + 1; |
| r2 emssr | 131 | out += src.slice(i, to); i = to; continue; |
| r2 emssr | 132 | } |
| r2 emssr | 133 | if (src[i] === BS && (src[i + 1] === '[' || src[i + 1] === '(')) { |
| r2 emssr | 134 | const close = BS + (src[i + 1] === '[' ? ']' : ')'); |
| r2 emssr | 135 | const end = src.indexOf(close, i + 2); |
| r2 emssr | 136 | if (end > 0) { |
| r2 emssr | 137 | push(src.slice(i + 2, end), src[i + 1] === '['); |
| r2 emssr | 138 | i = end + 2; continue; |
| r2 emssr | 139 | } |
| r2 emssr | 140 | } |
| r2 emssr | 141 | if (src.startsWith('$$', i)) { |
| r2 emssr | 142 | const end = src.indexOf('$$', i + 2); |
| r2 emssr | 143 | if (end > 0) { push(src.slice(i + 2, end), true); i = end + 2; continue; } |
| r2 emssr | 144 | } |
| r2 emssr | 145 | if (src[i] === '$' && !AFTER_OPEN.test(src[i + 1] || ' ')) { |
| r2 emssr | 146 | const end = src.indexOf('$', i + 1); |
| r2 emssr | 147 | const body = end > 0 ? src.slice(i + 1, end) : null; |
| r2 emssr | 148 | if (body !== null && !/\s$/.test(body) && !BLANK.test(body)) { |
| r2 emssr | 149 | push(body, false); i = end + 1; continue; |
| r2 emssr | 150 | } |
| r2 emssr | 151 | } |
| r2 emssr | 152 | out += src[i]; i++; |
| r2 emssr | 153 | } |
| r2 emssr | 154 | return { text: out, found: found }; |
| r2 emssr | 155 | } |
| r2 emssr | 156 | |
| r2 emssr | 157 | // Markdown が終わった後、印を KaTeX の出力へ戻す |
| r2 emssr | 158 | function putMath(root, found) { |
| r2 emssr | 159 | if (!found.length) return; |
| r2 emssr | 160 | const re = new RegExp(OPEN + '([0-9]+)' + SHUT); |
| r2 emssr | 161 | const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); |
| r2 emssr | 162 | const hits = []; |
| r2 emssr | 163 | for (let n = walk.nextNode(); n; n = walk.nextNode()) { |
| r2 emssr | 164 | if (re.test(n.nodeValue)) hits.push(n); |
| r2 emssr | 165 | } |
| r2 emssr | 166 | for (const node of hits) { |
| r2 emssr | 167 | const frag = document.createDocumentFragment(); |
| r2 emssr | 168 | let rest = node.nodeValue, m; |
| r2 emssr | 169 | while ((m = re.exec(rest))) { |
| r2 emssr | 170 | if (m.index) frag.appendChild(document.createTextNode(rest.slice(0, m.index))); |
| r2 emssr | 171 | const item = found[Number(m[1])]; |
| r2 emssr | 172 | const holder = document.createElement(item.display ? 'div' : 'span'); |
| r2 emssr | 173 | try { |
| r2 emssr | 174 | holder.innerHTML = katex.renderToString(item.tex, { |
| r2 emssr | 175 | displayMode: item.display, throwOnError: false, errorColor: 'var(--te)', |
| r2 emssr | 176 | }); |
| r2 emssr | 177 | } catch (e) { |
| r2 emssr | 178 | holder.textContent = item.tex; |
| r2 emssr | 179 | holder.style.color = 'var(--te)'; |
| r2 emssr | 180 | } |
| r2 emssr | 181 | frag.appendChild(holder); |
| r2 emssr | 182 | rest = rest.slice(m.index + m[0].length); |
| r2 emssr | 183 | } |
| r2 emssr | 184 | if (rest) frag.appendChild(document.createTextNode(rest)); |
| r2 emssr | 185 | node.replaceWith(frag); |
| r2 emssr | 186 | } |
| r2 emssr | 187 | } |
| r2 emssr | 188 | |
| r2 emssr | 189 | // ===== 描く ===== |
| r2 emssr | 190 | |
| r2 emssr | 191 | const MOVIE = /\.(mp4|webm|ogv|mov)$/i; |
| r2 emssr | 192 | const SOUND = /\.(mp3|wav|m4a|ogg)$/i; |
| r2 emssr | 193 | |
| r2 emssr | 194 | function mediaFix(root) { |
| r2 emssr | 195 | for (const img of [...root.querySelectorAll('img')]) { |
| r2 emssr | 196 | const src = img.getAttribute('src') || ''; |
| r2 emssr | 197 | if (MOVIE.test(src)) { |
| r2 emssr | 198 | const v = document.createElement('video'); |
| r2 emssr | 199 | v.src = src; v.controls = true; v.preload = 'metadata'; |
| r2 emssr | 200 | img.replaceWith(v); |
| r2 emssr | 201 | } else if (SOUND.test(src)) { |
| r2 emssr | 202 | const a = document.createElement('audio'); |
| r2 emssr | 203 | a.src = src; a.controls = true; |
| r2 emssr | 204 | img.replaceWith(a); |
| r2 emssr | 205 | } |
| r2 emssr | 206 | } |
| r2 emssr | 207 | for (const a of root.querySelectorAll('a[href^="http"]')) { |
| r2 emssr | 208 | a.target = '_blank'; |
| r2 emssr | 209 | a.rel = 'noreferrer noopener'; |
| r2 emssr | 210 | } |
| r2 emssr | 211 | } |
| r2 emssr | 212 | |
| r2 emssr | 213 | // 説明セルの本文を el へ描く。library が無い場合は素のまま出す |
| r2 emssr | 214 | function renderMarkdown(el, text) { |
| r2 emssr | 215 | const src = String(text || ''); |
| r2 emssr | 216 | if (!RENDER.ready) { |
| r2 emssr | 217 | el.textContent = src; |
| r2 emssr | 218 | return el; |
| r2 emssr | 219 | } |
| r2 emssr | 220 | const cut = takeMath(src); |
| r2 emssr | 221 | el.innerHTML = marked.parse(cut.text); |
| r2 emssr | 222 | sanitize(el); |
| r2 emssr | 223 | mediaFix(el); |
| r2 emssr | 224 | putMath(el, cut.found); |
| r2 emssr | 225 | return el; |
| r2 emssr | 226 | } |
| r2 emssr | 227 | |
| r2 emssr | 228 | // ===== assets ===== |
| r2 emssr | 229 | // |
| r2 emssr | 230 | // 画像と動画は server の assets/ へ置く。返りの path をそのまま Markdown に書ける。 |
| r2 emssr | 231 | |
| r2 emssr | 232 | async function uploadAsset(file) { |
| r2 emssr | 233 | const name = file.name.replace(/[^\w.\-]+/g, '_'); |
| r2 emssr | 234 | const r = await fetch('/assets/' + encodeURIComponent(name), |
| r2 emssr | 235 | { method: 'POST', body: file }); |
| r2 emssr | 236 | if (!r.ok) throw new Error('置けません: ' + r.status); |
| r2 emssr | 237 | return (await r.json()).path; |
| r2 emssr | 238 | } |
| r2 emssr | 239 | |
| r2 emssr | 240 | // 貼り付けた file を Markdown の一行にする |
| r2 emssr | 241 | function assetMarkdown(path, file) { |
| r2 emssr | 242 | if (MOVIE.test(path)) return '<video src="' + path + '" controls width="100%"></video>'; |
| r2 emssr | 243 | if (SOUND.test(path)) return '<audio src="' + path + '" controls></audio>'; |
| r2 emssr | 244 | return ''; |
| r2 emssr | 245 | } |
| r2 emssr | 246 | |
| r2 emssr | 247 | return { start: startRender, markdown: renderMarkdown, |
| r2 emssr | 248 | upload: uploadAsset, mark: assetMarkdown, state: RENDER }; |
| r2 emssr | 249 | |
| r2 emssr | 250 | })(); |
| r2 emssr | 251 | |
| r2 emssr | 252 | // app.js から呼ぶ名前。中身は Render の中にある |
| r2 emssr | 253 | const startRender = Render.start; |
| r2 emssr | 254 | const renderMarkdown = Render.markdown; |
| r2 emssr | 255 | const uploadAsset = Render.upload; |
| r2 emssr | 256 | const assetMarkdown = Render.mark; |