diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 145daa9..5d3bc34 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -34,15 +34,11 @@ jobs:
run: |
yarn
make linux
- - name: package
- run: |
- cd build/linux
- zip -r vivify-linux.zip *
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: vivify-linux
- path: build/linux/vivify-linux.zip
+ path: build/linux/*
build-macos:
name: Build MacOS
@@ -55,15 +51,11 @@ jobs:
run: |
yarn
make macos
- - name: package
- run: |
- cd build/macos
- zip -r vivify-macos.zip *
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: vivify-macos
- path: build/macos/vivify-macos.zip
+ path: build/macos/*
release:
name: Release
diff --git a/package.json b/package.json
index 02160bc..f951e6f 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,13 @@
"repository": "https://github.com/jannis-baum/vivify.git",
"author": "Jannis Baum",
"scripts": {
- "dev": "VIV_TIMEOUT=0 VIV_PORT=3000 nodemon --exec ts-node ./src/app.ts",
+ "dev": "VIV_TIMEOUT=0 VIV_PORT=3000 NODE_ENV=development nodemon --exec ts-node ./src/app.ts",
"viv": "VIV_PORT=3000 ./viv",
"lint": "eslint src static"
},
"dependencies": {
"@viz-js/viz": "^3.1.0",
+ "ansi_up": "^6.0.2",
"express": "^4.18.2",
"highlight.js": "^11.8.0",
"katex": "^0.15.6",
@@ -26,6 +27,7 @@
"ws": "^8.13.0"
},
"devDependencies": {
+ "@jupyterlab/nbformat": "^4.2.3",
"@types/express": "^4.17.17",
"@types/markdown-it": "^12.2.3",
"@types/node": "^20.4.2",
diff --git a/src/parser/ansi.ts b/src/parser/ansi.ts
new file mode 100644
index 0000000..b69dd0c
--- /dev/null
+++ b/src/parser/ansi.ts
@@ -0,0 +1,27 @@
+import { Renderer } from './parser';
+
+// this entire file is very ugly because ansi_up is only available as module so
+// we have to use a dynamic (async) import
+let _renderAnsi: Renderer | undefined = undefined;
+
+// here we asynchronously import ansi_up
+(async () => {
+ const { AnsiUp } = await (async () => {
+ // this gets even uglier because we can't directly use dynamic imports
+ // with ts-node https://github.com/TypeStrong/ts-node/discussions/1290
+ if (process.env.NODE_ENV === 'development') {
+ const dynamicImport = new Function('specifier', 'return import(specifier)');
+ return await dynamicImport('ansi_up');
+ }
+ // for compilation however we have to directly use dynamic imports
+ return await import('ansi_up');
+ })();
+ const ansiup = new AnsiUp();
+ _renderAnsi = (content: string): string => ansiup.ansi_to_html(content);
+})();
+
+// use identity while ansi_up loads
+const renderAnsi: Renderer = (content: string): string =>
+ _renderAnsi ? _renderAnsi(content) : content;
+
+export default renderAnsi;
diff --git a/src/parser/ipynb.ts b/src/parser/ipynb.ts
new file mode 100644
index 0000000..89945de
--- /dev/null
+++ b/src/parser/ipynb.ts
@@ -0,0 +1,117 @@
+import {
+ ICell,
+ ICodeCell,
+ IDisplayData,
+ IError,
+ IMimeBundle,
+ INotebookContent,
+ IOutput,
+ IStream,
+ MultilineString,
+ IExecuteResult,
+} from '@jupyterlab/nbformat';
+import renderAnsi from './ansi';
+import renderMarkdown from './markdown';
+import { Renderer } from './parser';
+
+function joinMultilineString(str: MultilineString): string {
+ return Array.isArray(str) ? str.join('') : str;
+}
+
+function contain(
+ content: MultilineString,
+ classNames: string | string[],
+ tag: string = 'div',
+): string {
+ function prefix(classNames: string | string[]): string {
+ if (Array.isArray(classNames)) {
+ return classNames.map((c) => prefix(c)).join(' ');
+ }
+ return `ipynb-${classNames}`;
+ }
+ classNames = prefix(classNames);
+
+ return `<${tag} class="${classNames}">${joinMultilineString(content)}${tag}>`;
+}
+
+function escapeHTML(raw: string): string {
+ return raw.replace(//g, '>');
+}
+
+function executionCount(count: ICell['execution_count']): string {
+ return contain(`[${count?.toString() ?? ' '}]:`, 'execution-count');
+}
+
+const renderNotebook: Renderer = (content: string): string => {
+ const nb = JSON.parse(content) as INotebookContent;
+ const language = nb.metadata.kernelspec?.language ?? nb.metadata.language_info?.name;
+
+ function renderDisplayData(data: IMimeBundle): string {
+ const identity = (content: string) => content;
+ const renderMap: [string, (content: string) => string][] = [
+ ['image/png', (content) => ``],
+ ['image/jpeg', (content) => ``],
+ ['image/svg+xml', identity],
+ ['text/svg+xml', identity],
+ ['text/html', identity],
+ ['text/markdown', renderMarkdown],
+ ['text/plain', (content) => contain(escapeHTML(content), 'output-plain', 'pre')],
+ ];
+ const result = renderMap.find(([mimeType]) => mimeType in data);
+ if (!result) return '';
+ const [format, render] = result;
+ return render(joinMultilineString(data[format] as MultilineString));
+ }
+
+ function renderOutput(output: IOutput): string {
+ switch (output.output_type) {
+ case 'stream':
+ const text = joinMultilineString((output as IStream).text);
+ return contain(
+ renderAnsi(text),
+ `output-stream-${(output as IStream).name}`,
+ 'pre',
+ );
+ case 'error':
+ const traceback = (output as IError).traceback.join('\n');
+ return contain(renderAnsi(traceback), 'output-error', 'pre');
+ case 'display_data': {
+ const displayData = (output as IDisplayData).data;
+ return renderDisplayData(displayData);
+ }
+ case 'execute_result': {
+ const displayData = (output as IExecuteResult).data;
+ return contain(
+ [executionCount(output.execution_count), renderDisplayData(displayData)],
+ 'output-execution-result',
+ );
+ }
+ default:
+ return '';
+ }
+ }
+
+ function renderCell(cell: ICell): string {
+ const source = joinMultilineString(cell.source);
+
+ switch (cell.cell_type) {
+ case 'code':
+ const content = [
+ executionCount(cell.execution_count),
+ renderMarkdown(`\`\`\`${language}\n${source}\n\`\`\``),
+ ];
+ if (Array.isArray(cell.outputs)) {
+ content.push(...(cell as ICodeCell).outputs.map(renderOutput));
+ }
+ return contain(content, 'cell-code');
+ case 'markdown':
+ return contain(renderMarkdown(source), 'cell-markdown');
+ default:
+ // 'raw' cells aren't normally rendered
+ return '';
+ }
+ }
+
+ return nb.cells.map(renderCell).join('\n\n');
+};
+export default renderNotebook;
diff --git a/src/parser/markdown.ts b/src/parser/markdown.ts
new file mode 100644
index 0000000..44b0e8c
--- /dev/null
+++ b/src/parser/markdown.ts
@@ -0,0 +1,36 @@
+import MarkdownIt from 'markdown-it';
+import anchor from 'markdown-it-anchor';
+import highlight from './highlight';
+import graphviz from './dot';
+import config from './config';
+import { Renderer } from './parser';
+
+const mdit = new MarkdownIt({
+ html: true,
+ highlight: highlight,
+ linkify: true,
+});
+
+mdit.use(anchor, {
+ permalink: anchor.permalink.ariaHidden({
+ placement: 'before',
+ }),
+});
+/* eslint-disable @typescript-eslint/no-var-requires */
+mdit.use(require('markdown-it-emoji'));
+mdit.use(require('markdown-it-task-lists'));
+mdit.use(require('markdown-it-footnote'));
+mdit.use(require('markdown-it-inject-linenumbers'));
+mdit.use(require('markdown-it-texmath'), {
+ engine: require('katex'),
+ delimiters: 'dollars',
+ katexOptions: config.katexOptions,
+});
+mdit.use(require('markdown-it-deflist'));
+/* eslint-enable @typescript-eslint/no-var-requires */
+mdit.use(graphviz);
+
+const renderMarkdown: Renderer = (content: string) => {
+ return mdit.render(content);
+};
+export default renderMarkdown;
diff --git a/src/parser/parser.ts b/src/parser/parser.ts
index 8712ce2..22e4535 100644
--- a/src/parser/parser.ts
+++ b/src/parser/parser.ts
@@ -1,47 +1,55 @@
+import { Dirent, readdirSync } from 'fs';
import { homedir } from 'os';
-
-import MarkdownIt from 'markdown-it';
-import anchor from 'markdown-it-anchor';
-import highlight from './highlight';
-import graphviz from './dot';
+import { join as pjoin } from 'path';
+import { pathToURL } from '../utils/path';
import config from './config';
+import renderNotebook from './ipynb';
+import renderMarkdown from './markdown';
+
+export type Renderer = (content: string) => string;
+
+const pathHeading: Renderer = (path: string) => `# \`${path.replace(homedir(), '~')}\``;
+const wrap = (contentType: string, content: string) =>
+ `
${content}
`;
+
+const mdExtensions = config.mdExtensions ?? ['markdown', 'md', 'mdown', 'mdwn', 'mkd', 'mkdn'];
+function textRenderer(
+ fileEnding: string | undefined,
+): { render: Renderer; contentType: string } | undefined {
+ if (!fileEnding) return undefined;
+ if (mdExtensions.includes(fileEnding)) {
+ return { render: renderMarkdown, contentType: 'markdown' };
+ }
+ if (fileEnding === 'ipynb') {
+ return { render: renderNotebook, contentType: 'ipynb' };
+ }
+}
-const mdit = new MarkdownIt({
- html: true,
- highlight: highlight,
- linkify: true,
-});
-
-mdit.use(anchor, {
- permalink: anchor.permalink.ariaHidden({
- placement: 'before',
- }),
-});
-/* eslint-disable @typescript-eslint/no-var-requires */
-mdit.use(require('markdown-it-emoji'));
-mdit.use(require('markdown-it-task-lists'));
-mdit.use(require('markdown-it-footnote'));
-mdit.use(require('markdown-it-inject-linenumbers'));
-mdit.use(require('markdown-it-texmath'), {
- engine: require('katex'),
- delimiters: 'dollars',
- katexOptions: config.katexOptions,
-});
-mdit.use(require('markdown-it-deflist'));
-/* eslint-enable @typescript-eslint/no-var-requires */
-mdit.use(graphviz);
-
-export const pathHeading = (path: string) => `# \`${path.replace(homedir(), '~')}\``;
-
-export default function parse(src: string, path?: string) {
- let md = src;
-
- const mdExtensions = config.mdExtensions ?? ['markdown', 'md', 'mdown', 'mdwn', 'mkd', 'mkdn'];
-
+export function renderTextFile(content: string, path: string): string {
const fileEnding = path?.split('.')?.at(-1);
- if (fileEnding && !mdExtensions.includes(fileEnding)) {
- md = `${pathHeading(path!)}\n\n\`\`\`${fileEnding}\n${src}\n\`\`\``;
+ const renderInformation = textRenderer(fileEnding);
+ if (renderInformation === undefined) {
+ return wrap(
+ 'txt',
+ renderMarkdown(`${pathHeading(path!)}\n\n\`\`\`${fileEnding}\n${content}\n\`\`\``),
+ );
}
+ const { render, contentType } = renderInformation;
+ return wrap(contentType, render(content));
+}
- return mdit.render(md);
+const dirListItem = (item: Dirent, path: string) =>
+ `${item.name}`;
+
+export function renderDirectory(path: string): string {
+ const list = readdirSync(path, { withFileTypes: true })
+ .sort((a, b) => +b.isDirectory() - +a.isDirectory())
+ .map((item) => dirListItem(item, path))
+ .join('\n');
+ return wrap(
+ 'directory',
+ renderMarkdown(`${pathHeading(path)}\n\n`),
+ );
}
diff --git a/src/routes/viewer.ts b/src/routes/viewer.ts
index c1dfcc6..1c84999 100644
--- a/src/routes/viewer.ts
+++ b/src/routes/viewer.ts
@@ -1,12 +1,12 @@
-import { Dirent, lstatSync, readdirSync, readFileSync } from 'fs';
+import { lstatSync, readFileSync } from 'fs';
import { dirname as pdirname, join as pjoin } from 'path';
import { Request, Response, Router } from 'express';
import { messageClientsAt } from '../app';
import config from '../parser/config';
-import parse, { pathHeading } from '../parser/parser';
import { pathToURL, pcomponents, pmime } from '../utils/path';
+import { renderDirectory, renderTextFile } from '../parser/parser';
export const router = Router();
@@ -22,11 +22,6 @@ const pageTitle = (path: string) => {
} else return pjoin(...comps.slice(-2));
};
-const dirListItem = (item: Dirent, path: string) =>
- `${item.name}`;
-
router.get(/.*/, async (req: Request, res: Response) => {
const path = res.locals.filepath;
@@ -34,21 +29,17 @@ router.get(/.*/, async (req: Request, res: Response) => {
if (!body) {
try {
if (lstatSync(path).isDirectory()) {
- const list = readdirSync(path, { withFileTypes: true })
- .sort((a, b) => +b.isDirectory() - +a.isDirectory())
- .map((item) => dirListItem(item, path))
- .join('\n');
- body = parse(`${pathHeading(path)}\n\n`);
+ body = renderDirectory(path);
} else {
const data = readFileSync(path);
const type = pmime(path);
- if (!type.startsWith('text/')) {
+ if (!(type.startsWith('text/') || type === 'application/json')) {
res.setHeader('Content-Type', type).send(data);
return;
}
- body = parse(data.toString(), path);
+ body = renderTextFile(data.toString(), path);
}
} catch {
res.status(404).send('File not found.');
@@ -70,6 +61,7 @@ router.get(/.*/, async (req: Request, res: Response) => {
${title}
+
\n",
+ "\n",
+ " \n",
+ " \n",
+ " | \n",
+ " a | \n",
+ " b | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 | \n",
+ " 1 | \n",
+ " 10 | \n",
+ "
\n",
+ " \n",
+ " 1 | \n",
+ " 2 | \n",
+ " 20 | \n",
+ "
\n",
+ " \n",
+ " 2 | \n",
+ " 3 | \n",
+ " 30 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ ""
+ ],
+ "text/plain": [
+ " a b\n",
+ "0 1 10\n",
+ "1 2 20\n",
+ "2 3 30"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import pandas as pd\n",
+ "df = pd.DataFrame({ 'a': [1, 2, 3], 'b': [10, 20, 30] })\n",
+ "df"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "f7914d4f-2f2b-4a56-99d3-2b1252380b6a",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAMoAAAB4CAYAAACzZ23WAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAQz0lEQVR4nO2de2yU5Z7HP3NvCzMdCvYmrYByKtgAZ5FCRT2sW8ETYZfLH8Y/VjREjGlRrP/AZgPBP7YxhpSIVXOIojEhGCOFI7vLykFpw1kqKyxnrUgPcNCitS0gvdDLdC7v/vHMdGbobToX35n290ma9H3eZ573N5Pf931uv+d5DJqmaQiCMCpGvQ0QhFRAhCIIESBCEYQIEKEIQgSIUAQhAkQoghABIhRBiACz3gbcic/no6WlBbvdjsFg0NscYQKjaRrd3d3k5+djNI5eZySdUFpaWigoKNDbDGESce3aNWbOnDlqnqQTit1uB5TxDodDZ2uEiUxXVxcFBQWDPjcaSSeUQHPL4XCIUITY6WqBy3+C3/4zjNCUj6SJn3RCEYSYuX0dvjsCjYfgh/8GNMj/O8gtjrpIEYowMei7Bd8dhcZP4Wo9aN7gvYJlMNATU/EiFCF1cXVD038qcVw+AT538F7+b6F4A8xfC87YB4dEKJMcr9eL2+0eO2Oy4O6H70/Bpc/hhz+D16XSM3Ihay7MLYP7HodphVgsFkwmU1weK0KZpGiaRmtrKx0dHXqbMjaaBp5+cPeCuw80C+Q9qf6MFrBmgCUDTBaVv8MLHVcBcDqd5ObmxjwnJ0KZpAREkp2dTUZGRvJN7mqa6le4OqH/tj8xQ/0ZLZDmAKsDLGnDjmZpmkZvby/t7e0A5OXlxWSOCGUS4vV6B0Uyffp0vc0JEhBH3y3o7wCfR6WbAaMV0p2QPk3VHhEIOz09HYD29nays7NjaoaJUCYhgT5JRkaGzpagxOHuVeLo6wjvkBvNkOZUArFOjUgcdxL4jm63W4QiRIduzS1NU32Nfr84vAMhRpkgLVPVHLapYIgtbjde31GEIvx6uPv9Ncet4GgVKDHY/OJIs8csjkQgQhESi8cVbFZ5+kJuGFSHPH0a2BxgjM8wbqIQoQjxxzOgOuN9t1T/YxAD2Oz+miMz6cURighFiA9ed1Acd4aLWO2qQ57mBFNqulzyNQaF1MHrgZ4bcOMStDVC549BkVinQOZMyCmGGffBlBlxE8mxY8d4+OGHcTqdTJ8+ndWrV3PlypW4lD0SqSlvIe5omkaf2zt2Rp8X+rtUn2OgGwjZaNSSoWqNNCeY/bPkXpSgRiHdYhrX6FRPTw+VlZUsWLCA27dvs2PHDtatW8f58+fHXKkYLSIUAYA+t5f5O/5Ll2dfeG0VGdbIXXHDhg1h1++//z533XUXFy5coLg4+lD60ZCml5ByXLp0iaeffpo5c+bgcDiYNWsWAM3NzQl7ptQoAqCaPxd2PQ6uHtWscnWGr+kwWYOz5Obh46tiefZ4WLNmDffccw/79u0jPz8fn89HcXExAwMDY384SkQokx1Ng4HbGPpukdHXERSHGTDaxh1flWhu3rxJU1MT+/bt45FHHgHg1KlTCX+uCGUyomlqIrC7FTq6g8GHEBJfNU2NXCWBOEKZNm0a06dP5w9/+AN5eXk0Nzezbdu2hD9XhDJZ0DT4+S9qNeDfvoKFr0KfD8wGFV8VmOew2ZNOHKEYjUYOHjzISy+9RHFxMUVFRbz55pusWLEioc8dl1Cqqqo4dOgQFy9eJD09nYceeojXX3+doqKiwTz9/f28+uqrHDx4EJfLxapVq3j77bfJycmJu/FCBLR/p8TReAh+8c81TC0IxldlzvCLI3XGdcrKyrhw4UJYWqLPwxrXr1NXV0d5eTkNDQ0cP34ct9vNypUr6ekJzsS+8sorfPbZZ3zyySfU1dXR0tLC+vXr4264MAo3r0D9G/B2Kby9TP3/yxXVCZ//T/DE6+C4GzLvVqEkKSQSvRhXjXLs2LGw6w8++IDs7GzOnj3Lo48+SmdnJ++99x4HDhzgscceA2D//v3MmzePhoYGli1bFj/LhXA6rsG3tar2+Pl8MN1ogfvK1EYLRU+o2qO/H65e1c3UVCSmPkpnZycAWVlZAJw9exa3201ZWdlgnvvvv5/CwkJOnz49rFBcLhcuVzDkuqurKxaTJhfdbXDhsBLHta+C6QYTzPmdEsf9T6qOuRATUQvF5/OxdetWli9fPjgb2traitVqxel0huXNycmhtbV12HKqqqrYtWtXtGZMPnpuwnd/VOL4/hTBEBID3LMciter5tWUGXpaOeGIWijl5eU0NjbGPIa9fft2KisrB68D+8EKIfR3wsV/949YnQwfzp25JLh/lSO2DRSEkYlKKBUVFRw9epT6+vqwXcBzc3MZGBigo6MjrFZpa2sjNzd32LJsNhs2my0aMyY2Az1qc7dva9UeVqHLZXMXqJrjgXUwbZZuJk4mxiUUTdPYsmULtbW1nDx5ktmzZ4fdX7x4MRaLhRMnTgwGrjU1NdHc3ExpaWn8rJ6ouPvh8nE1lPvXY+GLnmYUqZqjeD3MmKufjZOUcQmlvLycAwcOcOTIEex2+2C/IzMzk/T0dDIzM9m0aROVlZVkZWXhcDjYsmULpaWlMuI1El43XPkSvj2k9s4d6A7emzZbCaN4A2TPT+qJwInOuITyzjvvAAyZBd2/fz/PPvssANXV1RiNRjZs2BA24SiE4POqjnjjp6pj3ncreM9xt2pSFW9Q++eKOJKCcTe9xiItLY2amhpqamqiNmpC4vPBj2eUOL49DD3twXtT7gqKY2YJJGjx0URgxYoVLFq0iD179vyqz5VYr0SiadDyv35x1ELXT8F76dNg3j8qccx6OKU2WpiMiFDijaZB+4VgfNWtkBlwqx3mrVbimLMiuKm0kPSIUOLFjcv+muMQXL8YTDenQ9HvVaf8vsfVptJCTHg8HioqKvjoo4+wWCy8+OKLvPbaawnd+VKEEgu3flDCaDwErf8XTDdZYe5K1e/4zRNqa9BkJ7AHsB6Mc1HYhx9+yKZNmzhz5gxff/01mzdvprCwkOeffz5hJopQxkvXz8H4qh//J5huNMOcv1c1x/1PqqjcVMLdC/+Wr8+z/6VFLRKLkIKCAqqrqzEYDBQVFfHNN99QXV0tQtGdnhtwIXB45p8Ji6+a/Qg8sF51zKck0REKE5hly5aFNbNKS0vZvXs3Xq83bids3YkIZST6OuCi//DMv9UNPTwzEHxoHz40J+WwZKg3u17PTnJEKKG4boccnvmn8LM68hap0aoH1sXl8Mykw2AYV/NHT7766quw64aGBubOnZuw2gREKOqcjkufK3H89fPwHdez5/uDD9fD9Hv1s1EIo7m5mcrKSl544QXOnTvH3r172b17d0KfOTmF4hmAK18ocTT9BwzcDt7LujcYfJg9Tz8bhRF55pln6Ovro6SkBJPJxMsvv8zmzZsT+szJIxSvB76v98dXfabWeATILAgGH+YukPiqJObkyZOD/wdiD38NJrZQfD5oPq3mOr49DL03gvem5vrjq9arxU8iDmEUJp5QNA1+OheMr+oOGcnJmK5Gqh5YD/c8JPFVQsRMDKFomjqfIxBf1fFD8J4tE+atgeJ1MPt3El8lREVqC+V6kxJG46dw81Iw3TLFH1+1Ae77BzDLUmMhNlJPKL9cDcZXtTUG0002+M1KJY65q8Ca/JNYepPo3RWTgXh9x9QRis8HH672h5D4MVrg3sf8m7v9Xp0yK4yJxaKan729vaSnp+tsTWLp7VWBnoHvHC2pIxSjUS12Mhhh9qP+zd1WQ0aW3palHCaTCafTSXu7WmWZkZGR0BB1PdA0jd7eXtrb23E6nTHP2qeOUAAefw1WV8PUbL0tSXkC20cFxDJRcTqdI26VNR5SSygSRhI3DAYDeXl5ZGdn43a7x/5ACmKxWOIW/5VaQhHijslkSmgw4URBtvsQhAgQoQhCBIhQBCECRCiCEAEiFEGIABGKIESACEUQIkCEIggRIEIRhAgQoQhCBIhQBCECJNZLmJD4fBoDXh/9bi/9bh9ZU6xYzdHXCyIUIeF4fRouj3JY5bheXJ6gE/d7vLjcPn8elRaeP+Ta48U1XB5/Gep/HwMeX5gNf6xYzoKZzqi/gwhlkuHx+uj3DO+wrlCHC3HCMKf2XwfyDnXqQP5gHrdX3yXHZqNhiHDGXUacbBHGiaZpuL2a39lC36jDvEWHcWBXmAOHv5lHzOPx4fXp67QWk4E0swmbxUSaxYjNbCTNYvL/Gf33jHfk8d+zmEgzGwfT08zqc7bQNP/1YJlmI2ZT7F1xEQrKaV0eX/BNOcTphnu7hr5Zwx023MGHd2qXx4vOPovVbCQtxKmCDmYMd0DzcE4Yfh1w5jCnviO/zWzCZEzNJccJE0pNTQ1vvPEGra2tLFy4kL1791JSUhJTmd/82Mmt3oFBBwxz1uGc2RN+PZIIXB4fem9IEnxjBt+oaRblqHe+IUd16tC37h1v61AntpqMGFPUafUgIUL5+OOPqays5N1332Xp0qXs2bOHVatW0dTURHZ29Ovd//XwN/zlx86xM8aAwUDYGzT8zWoc1ulCndM2yht6JBHYzMYJt7nDRMOgJWBzp6VLl7JkyRLeeustAHw+HwUFBWzZsoVt27aN+tmuri4yMzPp7OzE4QjffuiVj89zsbV7SFt2SDNgmPbu8A6urm0hjmwxGcRpJwmj+dqdxL1GGRgY4OzZs2zfvn0wzWg0UlZWxunTp4fkd7lcuFyuweuurq4Ry65+alFcbRWESIm7UG7cuIHX6yUnJycsPScnh4sXLw7JX1VVxa5du4akjyYYQYgHAR+LpFGl+6jX9u3bqaysHLz+6aefmD9/PgUFE/D4NyEp6e7uJjNz9FOc4y6UGTNmYDKZaGtrC0tva2sbdiMym82GzRbcRHvq1Klcu3YNu90+pK/Q1dVFQUEB165dG7NNOdmQ32Z4RvtdNE2ju7ub/Pyxjw2Pu1CsViuLFy/mxIkTrF27FlCd+RMnTlBRUTHm541GIzNnzhw1j8PhEGcYAflthmek32WsmiRAQppelZWVbNy4kQcffJCSkhL27NlDT08Pzz33XCIeJwgJJyFCeeqpp7h+/To7duygtbWVRYsWcezYsSEdfEFIFRLWma+oqIioqTUebDYbO3fuDOvTCAr5bYYnXr9LQiYcBWGiISscBSECRCiCEAEiFEGIABGKIESACEUQIiClhFJTU8OsWbNIS0tj6dKlnDlzRm+TdKe+vp41a9aQn5+PwWDg8OHDepuUFFRVVbFkyRLsdjvZ2dmsXbuWpqamqMtLGaEEFoPt3LmTc+fOsXDhQlatWjXhD+sci56eHhYuXEhNTY3epiQVdXV1lJeX09DQwPHjx3G73axcuZKenp7oCtRShJKSEq28vHzw2uv1avn5+VpVVZWOViUXgFZbW6u3GUlJe3u7Bmh1dXVRfT4lapTAYrCysrLBtNEWgwnCnXR2qiXkWVlZUX0+JYQy2mKw1tZWnawSUgWfz8fWrVtZvnw5xcXFUZWh+8ItQUg05eXlNDY2curUqajLSAmhjHcxmCAEqKio4OjRo9TX14+5zmk0UqLpFboYLEBgMVhpaamOlgnJiqZpVFRUUFtbyxdffMHs2bNjKi8lahSQxWAjcfv2bS5fvjx4ffXqVc6fP09WVhaFhYU6WqYv5eXlHDhwgCNHjmC32wf7spmZmaSnp4+/wPgOwiWWvXv3aoWFhZrVatVKSkq0hoYGvU3SnS+//FIDhvxt3LhRb9N0ZbjfBND2798fVXmyHkUQIiAl+iiCoDciFEGIABGKIESACEUQIkCEIggRIEIRhAgQoQhCBIhQBCECRCiCEAEiFEGIABGKIETA/wP4fpn0CIAPxwAAAABJRU5ErkJggg==",
+ "text/plain": [
+ "