📦 Out of Context リポジトリ

リポジトリ / emssr / independent_common_compiler

r2
r2 emssr1
// 説明セルの描画。Markdown は marked、数式は KaTeX が描く。
r2 emssr2
//
r2 emssr3
//   Markdown   見出し、表、箇条書き、引用、code、画像、動画、素の HTML
r2 emssr4
//   数式       $…$ と $$…$$、\(…\) と \[…\]
r2 emssr5
//   画像と動画 assets/ へ置いたものを ![](assets/x.png) や <video> で貼る
r2 emssr6
//
r2 emssr7
// 素の HTML を通すので、描く前に許可した tag と属性だけへ落とす。
r2 emssr8
// 他人の notebook を `.json` で開けるため、この関門は外せない。
r2 emssr9
r2 emssr10
const Render = (function () {
r2 emssr11
r2 emssr12
const RENDER = { ready: false, base: null };
r2 emssr13
r2 emssr14
function loadCss(href) {
r2 emssr15
  const l = document.createElement('link');
r2 emssr16
  l.rel = 'stylesheet';
r2 emssr17
  l.href = href;
r2 emssr18
  document.head.appendChild(l);
r2 emssr19
}
r2 emssr20
r2 emssr21
function loadJs(src) {
r2 emssr22
  return new Promise((ok, no) => {
r2 emssr23
    const s = document.createElement('script');
r2 emssr24
    s.src = src; s.onload = ok; s.onerror = () => no(new Error(src));
r2 emssr25
    document.head.appendChild(s);
r2 emssr26
  });
r2 emssr27
}
r2 emssr28
r2 emssr29
// base は /vendor/web/ か CDN。setup.py が置いていれば手元のコピーを使う
r2 emssr30
async function startRender(base) {
r2 emssr31
  RENDER.base = base;
r2 emssr32
  loadCss(base + 'katex.min.css');
r2 emssr33
  await loadJs(base + 'marked.umd.js');
r2 emssr34
  await loadJs(base + 'katex.min.js');
r2 emssr35
  marked.setOptions({ gfm: true, breaks: false });
r2 emssr36
  RENDER.ready = true;
r2 emssr37
}
r2 emssr38
r2 emssr39
// ===== 関門 =====
r2 emssr40
r2 emssr41
const TAGS = new Set((
r2 emssr42
  'p br hr h1 h2 h3 h4 h5 h6 strong b em i del s u code pre blockquote ' +
r2 emssr43
  'ul ol li dl dt dd table thead tbody tfoot tr th td caption ' +
r2 emssr44
  'a img video audio source track figure figcaption ' +
r2 emssr45
  'span div sup sub kbd mark small abbr details summary ' +
r2 emssr46
  'svg g path circle ellipse rect line polyline polygon text tspan defs marker'
r2 emssr47
).split(' '));
r2 emssr48
r2 emssr49
const ATTRS = {
r2 emssr50
  a: ['href', 'title', 'target', 'rel'],
r2 emssr51
  img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
r2 emssr52
  video: ['src', 'controls', 'width', 'height', 'poster', 'loop', 'muted',
r2 emssr53
          'playsinline', 'preload', 'autoplay'],
r2 emssr54
  audio: ['src', 'controls', 'preload', 'loop'],
r2 emssr55
  source: ['src', 'type'],
r2 emssr56
  track: ['src', 'kind', 'srclang', 'label', 'default'],
r2 emssr57
  th: ['colspan', 'rowspan', 'align'],
r2 emssr58
  td: ['colspan', 'rowspan', 'align'],
r2 emssr59
  ol: ['start', 'type'],
r2 emssr60
  details: ['open'],
r2 emssr61
  svg: ['viewbox', 'width', 'height', 'xmlns', 'preserveaspectratio'],
r2 emssr62
};
r2 emssr63
// 図形は形の欄だけ通す
r2 emssr64
const SHAPE = ['d', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
r2 emssr65
               'width', 'height', 'points', 'fill', 'stroke', 'stroke-width',
r2 emssr66
               'stroke-dasharray', 'stroke-linecap', 'opacity', 'transform',
r2 emssr67
               'text-anchor', 'font-size', 'font-family', 'dominant-baseline'];
r2 emssr68
const COMMON_ATTRS = ['class', 'align', 'style'];
r2 emssr69
r2 emssr70
const URL_OK = /^(https?:|mailto:|assets\/|\.\/|\/(?!\/)|#|data:image\/(png|jpeg|gif|webp|svg\+xml);base64,)/i;
r2 emssr71
// style は文字揃えだけ許す。表の列揃えに marked が使う
r2 emssr72
const STYLE_OK = /^text-align:\s*(left|right|center)\s*;?$/i;
r2 emssr73
r2 emssr74
function sanitize(node) {
r2 emssr75
  for (const child of [...node.children]) {
r2 emssr76
    const tag = child.tagName.toLowerCase();
r2 emssr77
    if (!TAGS.has(tag)) {
r2 emssr78
      child.replaceWith(...child.childNodes);
r2 emssr79
      continue;
r2 emssr80
    }
r2 emssr81
    const allow = new Set([...(ATTRS[tag] || []), ...COMMON_ATTRS]);
r2 emssr82
    if (tag === 'svg' || child.closest('svg')) SHAPE.forEach(a => allow.add(a));
r2 emssr83
    for (const at of [...child.attributes]) {
r2 emssr84
      const name = at.name.toLowerCase();
r2 emssr85
      if (!allow.has(name)) { child.removeAttribute(at.name); continue; }
r2 emssr86
      if (name === 'style' && !STYLE_OK.test(at.value)) child.removeAttribute(at.name);
r2 emssr87
      if ((name === 'src' || name === 'href') && !URL_OK.test(at.value.trim())) {
r2 emssr88
        child.removeAttribute(at.name);
r2 emssr89
      }
r2 emssr90
    }
r2 emssr91
    sanitize(child);
r2 emssr92
  }
r2 emssr93
}
r2 emssr94
r2 emssr95
// ===== 数式 =====
r2 emssr96
//
r2 emssr97
// Markdown より先に数式を抜く。marked は行末の逆斜線二つを「escape した逆斜線」として
r2 emssr98
// 畳むので、後から当てると aligned の改行が消える。抜いた跡へ印を置き、Markdown の
r2 emssr99
// 後で戻す。
r2 emssr100
//
r2 emssr101
//   $$ … $$   \[ … \]     別行
r2 emssr102
//   $ … $     \( … \)     行の中。開いた直後と閉じる直前に空白を置かない
r2 emssr103
//
r2 emssr104
// ` と ``` の中は数式として読まない。
r2 emssr105
r2 emssr106
// 印は私用領域の二文字で挟む。CommonMark は U+0000 を U+FFFD へ置き換える決まりなので、
r2 emssr107
// NUL を印に使うと Markdown を通った時点で消える
r2 emssr108
const OPEN = String.fromCharCode(0xE000);
r2 emssr109
const SHUT = String.fromCharCode(0xE001);
r2 emssr110
const BS = '\\';
r2 emssr111
const BLANK = /\n\s*\n/;
r2 emssr112
const AFTER_OPEN = /[\s$]/;
r2 emssr113
r2 emssr114
function takeMath(src) {
r2 emssr115
  const found = [];
r2 emssr116
  let out = '', i = 0;
r2 emssr117
  const push = (tex, display) => {
r2 emssr118
    out += OPEN + found.length + SHUT;
r2 emssr119
    found.push({ tex: tex, display: display });
r2 emssr120
  };
r2 emssr121
  while (i < src.length) {
r2 emssr122
    // code fence と code span は素通しする
r2 emssr123
    if (src.startsWith('```', i)) {
r2 emssr124
      const end = src.indexOf('```', i + 3);
r2 emssr125
      const to = end < 0 ? src.length : end + 3;
r2 emssr126
      out += src.slice(i, to); i = to; continue;
r2 emssr127
    }
r2 emssr128
    if (src[i] === '`') {
r2 emssr129
      const end = src.indexOf('`', i + 1);
r2 emssr130
      const to = end < 0 ? src.length : end + 1;
r2 emssr131
      out += src.slice(i, to); i = to; continue;
r2 emssr132
    }
r2 emssr133
    if (src[i] === BS && (src[i + 1] === '[' || src[i + 1] === '(')) {
r2 emssr134
      const close = BS + (src[i + 1] === '[' ? ']' : ')');
r2 emssr135
      const end = src.indexOf(close, i + 2);
r2 emssr136
      if (end > 0) {
r2 emssr137
        push(src.slice(i + 2, end), src[i + 1] === '[');
r2 emssr138
        i = end + 2; continue;
r2 emssr139
      }
r2 emssr140
    }
r2 emssr141
    if (src.startsWith('$$', i)) {
r2 emssr142
      const end = src.indexOf('$$', i + 2);
r2 emssr143
      if (end > 0) { push(src.slice(i + 2, end), true); i = end + 2; continue; }
r2 emssr144
    }
r2 emssr145
    if (src[i] === '$' && !AFTER_OPEN.test(src[i + 1] || ' ')) {
r2 emssr146
      const end = src.indexOf('$', i + 1);
r2 emssr147
      const body = end > 0 ? src.slice(i + 1, end) : null;
r2 emssr148
      if (body !== null && !/\s$/.test(body) && !BLANK.test(body)) {
r2 emssr149
        push(body, false); i = end + 1; continue;
r2 emssr150
      }
r2 emssr151
    }
r2 emssr152
    out += src[i]; i++;
r2 emssr153
  }
r2 emssr154
  return { text: out, found: found };
r2 emssr155
}
r2 emssr156
r2 emssr157
// Markdown が終わった後、印を KaTeX の出力へ戻す
r2 emssr158
function putMath(root, found) {
r2 emssr159
  if (!found.length) return;
r2 emssr160
  const re = new RegExp(OPEN + '([0-9]+)' + SHUT);
r2 emssr161
  const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
r2 emssr162
  const hits = [];
r2 emssr163
  for (let n = walk.nextNode(); n; n = walk.nextNode()) {
r2 emssr164
    if (re.test(n.nodeValue)) hits.push(n);
r2 emssr165
  }
r2 emssr166
  for (const node of hits) {
r2 emssr167
    const frag = document.createDocumentFragment();
r2 emssr168
    let rest = node.nodeValue, m;
r2 emssr169
    while ((m = re.exec(rest))) {
r2 emssr170
      if (m.index) frag.appendChild(document.createTextNode(rest.slice(0, m.index)));
r2 emssr171
      const item = found[Number(m[1])];
r2 emssr172
      const holder = document.createElement(item.display ? 'div' : 'span');
r2 emssr173
      try {
r2 emssr174
        holder.innerHTML = katex.renderToString(item.tex, {
r2 emssr175
          displayMode: item.display, throwOnError: false, errorColor: 'var(--te)',
r2 emssr176
        });
r2 emssr177
      } catch (e) {
r2 emssr178
        holder.textContent = item.tex;
r2 emssr179
        holder.style.color = 'var(--te)';
r2 emssr180
      }
r2 emssr181
      frag.appendChild(holder);
r2 emssr182
      rest = rest.slice(m.index + m[0].length);
r2 emssr183
    }
r2 emssr184
    if (rest) frag.appendChild(document.createTextNode(rest));
r2 emssr185
    node.replaceWith(frag);
r2 emssr186
  }
r2 emssr187
}
r2 emssr188
r2 emssr189
// ===== 描く =====
r2 emssr190
r2 emssr191
const MOVIE = /\.(mp4|webm|ogv|mov)$/i;
r2 emssr192
const SOUND = /\.(mp3|wav|m4a|ogg)$/i;
r2 emssr193
r2 emssr194
function mediaFix(root) {
r2 emssr195
  for (const img of [...root.querySelectorAll('img')]) {
r2 emssr196
    const src = img.getAttribute('src') || '';
r2 emssr197
    if (MOVIE.test(src)) {
r2 emssr198
      const v = document.createElement('video');
r2 emssr199
      v.src = src; v.controls = true; v.preload = 'metadata';
r2 emssr200
      img.replaceWith(v);
r2 emssr201
    } else if (SOUND.test(src)) {
r2 emssr202
      const a = document.createElement('audio');
r2 emssr203
      a.src = src; a.controls = true;
r2 emssr204
      img.replaceWith(a);
r2 emssr205
    }
r2 emssr206
  }
r2 emssr207
  for (const a of root.querySelectorAll('a[href^="http"]')) {
r2 emssr208
    a.target = '_blank';
r2 emssr209
    a.rel = 'noreferrer noopener';
r2 emssr210
  }
r2 emssr211
}
r2 emssr212
r2 emssr213
// 説明セルの本文を el へ描く。library が無い場合は素のまま出す
r2 emssr214
function renderMarkdown(el, text) {
r2 emssr215
  const src = String(text || '');
r2 emssr216
  if (!RENDER.ready) {
r2 emssr217
    el.textContent = src;
r2 emssr218
    return el;
r2 emssr219
  }
r2 emssr220
  const cut = takeMath(src);
r2 emssr221
  el.innerHTML = marked.parse(cut.text);
r2 emssr222
  sanitize(el);
r2 emssr223
  mediaFix(el);
r2 emssr224
  putMath(el, cut.found);
r2 emssr225
  return el;
r2 emssr226
}
r2 emssr227
r2 emssr228
// ===== assets =====
r2 emssr229
//
r2 emssr230
// 画像と動画は server の assets/ へ置く。返りの path をそのまま Markdown に書ける。
r2 emssr231
r2 emssr232
async function uploadAsset(file) {
r2 emssr233
  const name = file.name.replace(/[^\w.\-]+/g, '_');
r2 emssr234
  const r = await fetch('/assets/' + encodeURIComponent(name),
r2 emssr235
    { method: 'POST', body: file });
r2 emssr236
  if (!r.ok) throw new Error('置けません: ' + r.status);
r2 emssr237
  return (await r.json()).path;
r2 emssr238
}
r2 emssr239
r2 emssr240
// 貼り付けた file を Markdown の一行にする
r2 emssr241
function assetMarkdown(path, file) {
r2 emssr242
  if (MOVIE.test(path)) return '<video src="' + path + '" controls width="100%"></video>';
r2 emssr243
  if (SOUND.test(path)) return '<audio src="' + path + '" controls></audio>';
r2 emssr244
  return '![' + file.name + '](' + path + ')';
r2 emssr245
}
r2 emssr246
r2 emssr247
return { start: startRender, markdown: renderMarkdown,
r2 emssr248
         upload: uploadAsset, mark: assetMarkdown, state: RENDER };
r2 emssr249
r2 emssr250
})();
r2 emssr251
r2 emssr252
// app.js から呼ぶ名前。中身は Render の中にある
r2 emssr253
const startRender = Render.start;
r2 emssr254
const renderMarkdown = Render.markdown;
r2 emssr255
const uploadAsset = Render.upload;
r2 emssr256
const assetMarkdown = Render.mark;