feat: Add support for rendering .ipynb Jupyter/IPython notebooks (#491)

This commit is contained in:
Fabio Manganiello
2025-09-21 03:48:59 +02:00
committed by GitHub
parent 53ce41e0e4
commit c02bf97b63
12 changed files with 312 additions and 13 deletions

View File

@@ -4,5 +4,5 @@ package public
import "embed"
//go:embed manifest.json assets/*.js assets/*.css assets/*.svg assets/*.png
//go:embed manifest.json assets/*.js assets/*.css assets/*.svg assets/*.png assets/*.ttf assets/*.woff assets/*.woff2
var Files embed.FS

View File

@@ -1,3 +1,4 @@
import './ipynb';
import PDFObject from 'pdfobject';
document.querySelectorAll<HTMLElement>('.table-code').forEach((el) => {
@@ -79,5 +80,4 @@ if (document.getElementById('gist').dataset.own) {
document.querySelectorAll(".pdf").forEach((el) => {
PDFObject.embed(el.dataset.src || "", el);
})
})

15
public/ipynb.css vendored Normal file
View File

@@ -0,0 +1,15 @@
.jupyter.notebook {
padding: 0.5rem;
}
.jupyter.notebook pre {
font-size: 0.8em !important;
}
.jupyter.notebook .jupyter-cell {
padding: 0.5rem;
}
.jupyter.notebook .jupyter-cell.code-cell {
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5));
}

127
public/ipynb.ts Normal file
View File

@@ -0,0 +1,127 @@
import hljs from 'highlight.js';
import latex from './latex';
import showdown from 'showdown';
class IPynb {
private element: HTMLElement;
private cells: HTMLElement[] = [];
private language: string = 'python';
private notebook: any;
constructor(element: HTMLElement) {
this.element = element;
let notebookContent = element.innerText;
try {
this.notebook = JSON.parse(notebookContent);
} catch (e) {
console.error('Failed to parse Jupyter notebook content:', e);
return;
}
if (!this.notebook) {
console.error('Failed to parse Jupyter notebook content:', notebookContent);
return;
}
this.language = this.notebook.metadata.kernelspec?.language || 'python';
this.cells = this.createCells();
}
mount() {
const parent = this.element.parentElement as HTMLElement;
parent.removeChild(this.element);
parent.innerHTML = this.cells
.filter((cell: HTMLElement) => !!cell?.outerHTML)
.map((cell: HTMLElement) => cell.outerHTML)
.join('');
}
private getOutputs(cell: any): HTMLElement[] {
return (cell.outputs || []).map((output: any) => {
const outputElement = document.createElement('div');
outputElement.classList.add('jupyter-output');
if (output.output_type === 'stream') {
const textElement = document.createElement('pre');
textElement.classList.add('stream-output');
textElement.textContent = output.text.join('');
outputElement.appendChild(textElement);
} else if (output.output_type === 'display_data' || output.output_type === 'execute_result') {
if (output.data['text/plain']) {
outputElement.innerHTML += `\n<pre>${output.data['text/plain']}</pre>`;
}
if (output.data['text/html']) {
outputElement.innerHTML += '\n' + output.data['text/html'];
}
const images = Object.keys(output.data).filter(key => key.startsWith('image/'));
if (images.length > 0) {
const imgEl = document.createElement('img');
const imgType = images[0]; // Use the first image type found
imgEl.src = `data:${imgType};base64,${output.data[imgType]}`;
outputElement.innerHTML += imgEl.outerHTML;
}
} else if (output.output_type === 'execute_result') {
if (output.data['text/plain']) {
outputElement.innerHTML += `<pre>${output.data['text/plain']}</pre>`;
}
if (output.data['text/html']) {
outputElement.innerHTML += output.data['text/html'];
}
if (output.data['image/png']) {
const imgEl = document.createElement('img');
imgEl.src = `data:image/png;base64,${output.data['image/png']}`;
outputElement.appendChild(imgEl);
}
} else if (output.output_type === 'error') {
outputElement.classList.add('error');
outputElement.textContent = `Error: ${output.ename}: ${output.evalue}`;
}
return outputElement;
});
}
private createCellElement(cell: any): HTMLElement {
const cellElement = document.createElement('div');
const source = cell.source.join('');
cellElement.classList.add('jupyter-cell');
switch (cell.cell_type) {
case 'markdown':
const converter = new showdown.Converter();
cellElement.classList.add('markdown-cell');
cellElement.innerHTML = `<div class="markdown-body">${converter.makeHtml(latex.render(source))}</div>`;
break;
case 'code':
cellElement.classList.add('code-cell');
cellElement.innerHTML = `<pre class="hljs"><code class="language-${this.language}">${source}</code></pre>`;
hljs.highlightElement(cellElement.querySelector('code') as HTMLElement);
break;
default:
break;
}
return cellElement;
}
private createCells(): HTMLElement[] {
return (this.notebook.cells || []).map((cell: any) => {
const container = document.createElement('div');
const cellElement = this.createCellElement(cell);
const outputs = this.getOutputs(cell);
container.classList.add('jupyter-cell-container');
container.appendChild(cellElement);
outputs.forEach((output: HTMLElement) => container.appendChild(output));
return container;
});
}
}
// Process Jupyter notebooks
document.querySelectorAll<HTMLElement>('.jupyter.notebook pre').forEach((el) => {
new IPynb(el).mount();
});

66
public/latex.ts Normal file
View File

@@ -0,0 +1,66 @@
import katex from 'katex';
const delimiters = [
{ left: '\\\$\\\$', right: '\\\$\\\$', multiline: true },
{ left: '\\\$', right: '\\\$', multiline: false },
{ left: '\\\\\[', right: '\\\\\]', multiline: true },
{ left: '\\\\\(', right: '\\\\\)', multiline: false },
];
const delimiterMatchers = delimiters.map(
(delimiter) => new RegExp(
`${delimiter.left}(.*?)${delimiter.right}`,
`g${delimiter.multiline ? 'ms' : ''}`
)
);
// Replace LaTeX delimiters in a string with KaTeX rendering
function render(text: string): string {
// Step 1: Replace all LaTeX expressions with placeholders
const expressions: Array<{ placeholder: string; latex: string; displayMode: boolean }> = [];
let modifiedText = text;
let placeholderIndex = 0;
// Process each delimiter type
delimiters.forEach((delimiter, i) => {
// Find all matches and replace with placeholders
modifiedText = modifiedText.replace(delimiterMatchers[i], (match, latex) => {
if (!latex.trim()) {
return match; // Return original if content is empty
}
const placeholder = `__KATEX_PLACEHOLDER_${placeholderIndex++}__`;
expressions.push({
placeholder,
latex,
displayMode: delimiter.multiline,
});
return placeholder;
});
});
// Step 2: Replace placeholders with rendered LaTeX
for (const { placeholder, latex, displayMode } of expressions) {
try {
const rendered = katex.renderToString(latex, {
throwOnError: false,
displayMode,
});
modifiedText = modifiedText.replace(placeholder, rendered);
} catch (error) {
console.error('KaTeX rendering error:', error);
// Replace placeholder with original LaTeX if rendering fails
modifiedText = modifiedText.replace(
placeholder,
displayMode ? `$$${latex}$$` : `$${latex}$`
);
}
}
return modifiedText;
}
export default {
render,
};

10
public/style.css vendored
View File

@@ -190,6 +190,16 @@ dl.dl-config dd {
@apply hidden !important;
}
/*
* A hack to ensure that Jupyter output images are always rendered with a
* neutral background color, even if the image itself does not have one, since
* Jupyter usually outputs images with transparent or light backgrounds.
*/
.dark .jupyter-output img {
background-color: #888;
}
.pdfobject-container {
@apply min-h-[700px] h-[700px] !important;
}

5
public/style.scss vendored
View File

@@ -1,9 +1,14 @@
@import "katex/dist/katex.min.css";
:root {
@import "highlight.js/scss/github";
@import "github-markdown-css/github-markdown-light";
@import './catppuccin-latte';
@import './ipynb';
}
.dark {
@import "highlight.js/scss/github-dark";
@import "github-markdown-css/github-markdown-dark";
@import './catppuccin-macchiato';
}