From d20d5f06a3a015b38c2068453faa2aa3f93f650d Mon Sep 17 00:00:00 2001 From: NeoVand Date: Thu, 2 Jul 2026 05:58:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(book):=20the=20LaTeX=20emitter=20=E2=80=94?= =?UTF-8?q?=20the=20guide=20compiles=20into=20a=20real=20book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/emit-latex.ts walks the same chapterBlocks the app renders and prints book/gradient-lab.tex; tectonic compiles it to a 61-page PDF. npm run book does both. One source of truth, screen and print — the whole point of the block migration, now closed end to end. - rich.ts gains richToTex/escapeTex: math passes through untouched, prose escapes in a single character pass (no rule can mangle another rule's output), theme spans take the light branch, the app's Unicode symbols map to robust TeX commands (5 new serializer tests) - every block kind maps to a print environment: displays become numbered equations labelled eq:, honesty notes take the outer margin under an ∞ small-caps tag, concept/recipe/proof/frontier share one quiet box style, proofs close with a QED rule, the bestiary and further-reading lists come from their content modules, and every chapter ends with its live gradientlab.ai deep link - figures emit labelled placeholders until the compute/render split; interactive widgets point at the app (print fallbacks later) - memoir + unicode-math + tcolorbox under tectonic (kaobook isn't in tectonic's bundle; memoir was the plan's designated fallback); chapter titles carry math via texorpdfstring - CI: .github/workflows/book.yml compiles the PDF artifact on every push to main that touches content or the emitter Co-Authored-By: Claude Fable 5 --- .github/workflows/book.yml | 35 +++++ .gitignore | 1 + package-lock.json | 34 +++++ package.json | 5 +- scripts/emit-latex.ts | 302 +++++++++++++++++++++++++++++++++++++ src/content/rich.test.ts | 31 +++- src/content/rich.ts | 114 +++++++++++++- 7 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/book.yml create mode 100644 scripts/emit-latex.ts diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml new file mode 100644 index 0000000..c08ba9c --- /dev/null +++ b/.github/workflows/book.yml @@ -0,0 +1,35 @@ +# Compiles the book from the same content the app renders. The PDF lands as +# a workflow artifact on every push to main that touches content or the +# emitter — the printed book can never drift from the live guide. +name: book + +on: + push: + branches: [main] + paths: + - 'src/content/**' + - 'scripts/emit-latex.ts' + - '.github/workflows/book.yml' + workflow_dispatch: + +jobs: + pdf: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: Emit LaTeX + run: npm run book:tex + - uses: wtfjoke/setup-tectonic@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Compile PDF + run: tectonic book/gradient-lab.tex + - uses: actions/upload-artifact@v4 + with: + name: gradient-lab-pdf + path: book/gradient-lab.pdf diff --git a/.gitignore b/.gitignore index 00db1d2..740e18f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sln *.sw? .claude/launch.json +book/ diff --git a/package-lock.json b/package-lock.json index bb58ceb..b4f165d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "svelte-check": "^4.4.6", "typescript": "^6.0.3", "vite": "^8.0.10", + "vite-node": "^6.0.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.8" } @@ -3437,6 +3438,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -7686,6 +7697,29 @@ } } }, + "node_modules/vite-node": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-6.0.0.tgz", + "integrity": "sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^7.0.0", + "es-module-lexer": "^2.0.0", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "vite": "^8.0.0" + }, + "bin": { + "vite-node": "dist/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://opencollective.com/antfu" + } + }, "node_modules/vite-plugin-pwa": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", diff --git a/package.json b/package.json index 6de99ab..f1f53dd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", - "test": "vitest run" + "test": "vitest run", + "book:tex": "vite-node scripts/emit-latex.ts", + "book": "npm run book:tex && tectonic book/gradient-lab.tex" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", @@ -19,6 +21,7 @@ "svelte-check": "^4.4.6", "typescript": "^6.0.3", "vite": "^8.0.10", + "vite-node": "^6.0.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.8" }, diff --git a/scripts/emit-latex.ts b/scripts/emit-latex.ts new file mode 100644 index 0000000..9f0ada1 --- /dev/null +++ b/scripts/emit-latex.ts @@ -0,0 +1,302 @@ +/** + * The LaTeX emitter — walks the same content the app renders and prints the + * book. Run with `npm run book:tex` (vite-node resolves the src imports); + * `npm run book` also compiles the PDF with tectonic. + * + * This is the skeleton pass of the pipeline (docs/plan/06 §4): every block + * kind maps to a print environment, the optimizer cards / bestiary / + * experiment set come from their content modules, honesty notes take the + * margin, and every chapter closes with its live link. Figures emit labelled + * placeholders until the compute/render split lands; interactive widgets + * point at the app. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { guideParts } from '../src/content/registry'; +import { chapterBlocks } from '../src/content/chapters'; +import type { Block, Rich } from '../src/content/blocks'; +import { formulas } from '../src/content/formulas'; +import { chRefs } from '../src/content/chapterRefs'; +import { optTree, OPT_CITE } from '../src/content/optimizerCards'; +import { problemCards } from '../src/content/problemCards'; +import { richToTex, escapeTex } from '../src/content/rich'; + +const SITE = 'https://gradientlab.ai'; + +// ---------- small helpers ---------- + +/** Card prose mixes light HTML with $math$; fold the tags into micro-syntax first. */ +const htmlishToTex = (s: string): string => + richToTex( + s + .replace(/<\/?em>/g, '*') + .replace(/<\/?strong>/g, '**') + ); + +const P = (text: Rich) => richToTex(text) + '\n'; + +// ---------- block emission ---------- + +function emitBlocks(blocks: Block[], slug: string): string { + const out: string[] = []; + for (const b of blocks) { + switch (b.kind) { + case 'p': + out.push(P(b.text)); + break; + case 'aside': + out.push(`\\begin{quotation}\\small ${richToTex(b.text)}\\end{quotation}`); + break; + case 'look': + // The app-pointing register: italic, flagged as live. + out.push(`\\begin{quotation}\\small\\itshape ${richToTex(b.text)}\\end{quotation}`); + break; + case 'display': + out.push(`\\begin{equation}\\label{eq:${b.formula}}\n${formulas[b.formula]}\n\\end{equation}`); + break; + case 'recipe': + out.push(`\\begin{kaobox}\n${richToTex(b.text)}\n\\end{kaobox}`); + break; + case 'list': + out.push( + '\\begin{itemize}\n' + + b.items.map(i => ` \\item ${richToTex(i)}`).join('\n') + + '\n\\end{itemize}' + ); + break; + case 'concept': + out.push(`\\begin{kaobox}[title={${richToTex(b.title)}}]\n${richToTex(b.text)}\n\\end{kaobox}`); + break; + case 'conceptOverlay': + out.push( + `\\begin{kaobox}[title={${richToTex(b.title)}}]\n` + + b.paras.map(t => richToTex(t)).join('\n\n') + + `\n\n\\figplaceholder{${b.fig}}\n\\end{kaobox}` + ); + break; + case 'proof': { + const inner = b.blocks + .map((pb, i) => { + if (pb.kind === 'p') { + const isLast = !b.blocks.slice(i + 1).some(x => x.kind === 'p'); + return richToTex(pb.text) + (isLast ? ' \\hfill\\rule{0.9ex}{0.9ex}' : ''); + } + if (pb.kind === 'display') + return `\\begin{equation}\\label{eq:${pb.formula}}\n${formulas[pb.formula]}\n\\end{equation}`; + if (pb.kind === 'figure') return emitFigure(pb.id, pb.caption, { inBox: true }); + return ''; + }) + .join('\n\n'); + out.push(`\\begin{kaobox}[title={${richToTex(b.title)}}]\n${inner}\n\\end{kaobox}`); + break; + } + case 'hd': + out.push(`\\marginnote{\\footnotesize \\(\\infty\\)\\,\\textsc{in a billion dimensions} — ${richToTex(b.text)}}`); + break; + case 'figure': + out.push(emitFigure(b.id, b.caption)); + break; + case 'widget': + out.push(emitWidget(b.id, slug)); + break; + case 'optcards': + out.push(emitCards(b.chapter)); + break; + case 'frontier': + out.push( + `\\begin{kaobox}[title={${richToTex(b.title)}}]\n${richToTex(b.text)}\n\\end{kaobox}` + ); + break; + } + } + return out.join('\n\n'); +} + +function emitFigure(id: string, caption: Rich, opts?: { inBox?: boolean }): string { + if (opts?.inBox) { + // Floats can't live inside a box; memoir's \legend gives the caption. + return [ + '\\begin{center}', + `\\figplaceholder{${id}}`, + `\\legend{${richToTex(caption)}}`, + '\\end{center}' + ].join('\n'); + } + return [ + '\\begin{figure}[htbp]', + '\\centering', + `\\figplaceholder{${id}}`, + `\\caption{${richToTex(caption)}}`, + `\\label{fig:${id}}`, + '\\end{figure}' + ].join('\n'); +} + +function emitWidget(id: string, slug: string): string { + if (id === 'problem-grid') return emitBestiary(); + if (id === 'experiments-list') return emitExperimentNote(); + if (id === 'keyboard') return '% keyboard map — app-only, dropped from print'; + // Interactive islands point home until their print fallbacks land. + return [ + '\\begin{figure}[htbp]', + '\\centering', + `\\figplaceholder{${id} (interactive)}`, + `\\caption{Interactive in the app: \\url{${SITE}/\\#ch=${slug}}}`, + `\\label{fig:${id}}`, + '\\end{figure}' + ].join('\n'); +} + +// ---------- the optimizer story cards ---------- + +function emitCards(chapter: string): string { + const out: string[] = []; + for (const c of optTree.filter(o => o.chapter === chapter)) { + if (c.act) { + out.push(`\\subsection*{${escapeTex(c.act.no)} — ${escapeTex(c.act.title)}}`); + if (c.act.intro) out.push(htmlishToTex(c.act.intro)); + } + if (c.lead) out.push(htmlishToTex(c.lead)); + out.push(`\\subsection{${escapeTex(c.name)}}`); + out.push(`\\noindent\\textsc{${escapeTex(c.year)}} · \\emph{${escapeTex(c.by)}}\\smallskip`); + out.push(htmlishToTex(c.idea)); + out.push(`\\[ ${c.formula} \\]`); + const foot: string[] = []; + if (c.fix) foot.push(`\\checkmark{}\\; ${richToTex(c.fix)}`); + if (c.brk) foot.push(`\\(\\times\\)\\; ${richToTex(c.brk)}`); + if (foot.length) out.push(`{\\small ${foot.join(' \\quad ')}\\par}`); + if (c.hd) + out.push(`\\marginnote{\\footnotesize \\(\\infty\\)\\,\\textsc{in a billion dimensions} — ${htmlishToTex(c.hd)}}`); + const cite = OPT_CITE[c.name]; + if (cite?.paper || cite?.wiki) { + const links: string[] = []; + if (cite.paper) links.push(`\\url{${cite.paper}}`); + else if (cite.wiki) links.push(`\\url{${cite.wiki}}`); + out.push(`{\\footnotesize ${cite.cite ? escapeTex(cite.cite) + ' — ' : ''}${links.join(', ')}\\par}`); + } + } + return out.join('\n\n'); +} + +// ---------- the bestiary + experiments ---------- + +function emitBestiary(): string { + const out: string[] = []; + for (const [group, list] of Object.entries(problemCards)) { + out.push(`\\subsection*{${escapeTex(group)}}`); + out.push('\\begin{itemize}'); + for (const p of list) { + out.push( + ` \\item \\textbf{${escapeTex(p.name)}} — \\(\\;\\)${escapeTex(p.formula)} \\;—\\; \\emph{${escapeTex(p.tag)}}` + ); + } + out.push('\\end{itemize}'); + } + return out.join('\n'); +} + +function emitExperimentNote(): string { + // The experiment cards are one-click app scenarios; in print they become + // the exercise seed (plan §5). Until the exercise pass, point at the app. + return `Every experiment is one click in the app: \\url{${SITE}/\\#ch=ch-experiments}`; +} + +// ---------- chapters, parts, book ---------- + +function emitChapter(slug: string, title: string): string { + const blocks = chapterBlocks[slug]; + if (!blocks) return ''; + const out: string[] = []; + // \( \) is fragile in moving arguments (toc, headers); $ is robust there — + // and hyperref needs a plain-text stand-in for its PDF bookmarks. + const titleTex = richToTex(title) + .replace(/\\[()]/g, '$') + .replace(/\$([^$]+)\$/g, (_, tex) => `\\texorpdfstring{$${tex}$}{${tex.replace(/\\/g, '')}}`); + out.push(`\\chapter{${titleTex}}\\label{ch:${slug}}`); + out.push(emitBlocks(blocks, slug)); + // every chapter ends at the app — its live deep link + out.push(`\\begin{kaobox}[title={Try it live}]\n\\url{${SITE}/\\#ch=${slug}}\n\\end{kaobox}`); + const refs = chRefs[slug]; + if (refs?.length) { + out.push('\\subsection*{Further reading}'); + out.push( + '\\begin{itemize}\n' + + refs.map(r => ` \\item ${escapeTex(r.label)} — \\url{${r.href}}`).join('\n') + + '\n\\end{itemize}' + ); + } + return out.join('\n\n'); +} + +const PREAMBLE = String.raw`% Generated by scripts/emit-latex.ts — do not edit by hand. +\documentclass[a4paper,11pt,twoside]{memoir} +\usepackage{amsmath} +\usepackage{unicode-math} +\usepackage{marginnote} +\usepackage[most]{tcolorbox} +\usepackage{hyperref} +\hypersetup{colorlinks=true, linkcolor=black, urlcolor=teal} +\urlstyle{same} + +% the reading layout: generous outer margin for the honesty channel +\setlrmarginsandblock{22mm}{58mm}{*} +\setulmarginsandblock{24mm}{28mm}{*} +\setmarginnotes{4mm}{46mm}{2mm} +\checkandfixthelayout + +% one quiet box style for concept/recipe/proof/frontier +\newtcolorbox{kaobox}[1][]{colback=black!4, colframe=black!25, boxrule=0.4pt, + arc=2mm, left=3mm, right=3mm, top=2mm, bottom=2mm, fonttitle=\bfseries, #1} + +% figure placeholders until the compute/render split lands +\newcommand{\figplaceholder}[1]{\fbox{\parbox[c][4cm][c]{0.85\linewidth}{\centering\ttfamily [figure: #1]}}} + +\title{Gradient Lab} +\author{Neo Mohsenvand} +\date{\today} + +\begin{document} +\frontmatter +\maketitle +\begin{center}\itshape +Two knobs, a landscape of error, and the search for its lowest point.\\ +Drag the marker, press Train, watch the loss fall — then learn why it does. +\end{center} +\cleardoublepage +\tableofcontents* +\mainmatter +`; + +function emitBook(): string { + const out: string[] = [PREAMBLE]; + for (const part of guideParts) { + const isReference = part.title === 'Reference'; + if (isReference) { + out.push('\\appendix'); + // the keyboard map is app-only; the panels reference earns an appendix + for (const ch of part.chapters) { + if (ch.slug === 'ch-keys') continue; + out.push(emitChapter(ch.slug, ch.title)); + } + continue; + } + // "Part I · The landscape" → \part{The landscape} (numbering is derived) + const title = part.title.replace(/^Part [IVX]+ · /, ''); + out.push(`\\part{${escapeTex(title)}}`); + for (const ch of part.chapters) out.push(emitChapter(ch.slug, ch.title)); + } + out.push('\\end{document}\n'); + return out.join('\n\n'); +} + +// ---------- write ---------- + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = join(here, '..', 'book'); +mkdirSync(outDir, { recursive: true }); +const tex = emitBook(); +writeFileSync(join(outDir, 'gradient-lab.tex'), tex); +console.log(`book/gradient-lab.tex — ${tex.length.toLocaleString()} chars, ${Object.keys(chapterBlocks).length} chapters`); diff --git a/src/content/rich.test.ts b/src/content/rich.test.ts index 30fc885..70c2a29 100644 --- a/src/content/rich.test.ts +++ b/src/content/rich.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseRich, richToHtml } from './rich'; +import { parseRich, richToHtml, richToTex, escapeTex } from './rich'; describe('parseRich', () => { it('parses plain text as one token', () => { @@ -95,3 +95,32 @@ describe('richToHtml', () => { expect(richToHtml(src, false)).toBe('the deep-coloured dimple'); }); }); + +describe('richToTex', () => { + it('passes math through and marks up prose', () => { + expect(richToTex('the knob $\\gamma$ is **big** and *fast*')).toBe( + 'the knob \\(\\gamma\\) is \\textbf{big} and \\emph{fast}' + ); + }); + + it('takes the light branch of theme spans', () => { + expect(richToTex('{dark:bright}{light:deep-coloured} dimple')).toBe('deep-coloured dimple'); + }); + + it('escapes TeX specials exactly once', () => { + expect(escapeTex('50% of A & B _under_ #1')).toBe('50\\% of A \\& B \\_under\\_ \\#1'); + expect(escapeTex('a {plain} brace')).toBe('a \\{plain\\} brace'); + }); + + it('maps the prose Unicode symbols to commands', () => { + expect(escapeTex('✓ done, κ = 10, ∇ℒ fades')).toBe( + '\\checkmark{} done, \\(\\kappa\\) = 10, \\(\\nabla\\)\\(\\mathcal{L}\\) fades' + ); + }); + + it('renders knob and ink spans in the print register', () => { + expect(richToTex('{g:$\\gamma$} and the {blue:blue arrow}')).toBe( + '\\emph{\\(\\gamma\\)} and the \\textbf{blue arrow}' + ); + }); +}); diff --git a/src/content/rich.ts b/src/content/rich.ts index 35cf9be..a192c7d 100644 --- a/src/content/rich.ts +++ b/src/content/rich.ts @@ -173,12 +173,120 @@ function tokensToHtml(tokens: RichToken[], dark: boolean): string { // only the (cheap) serialization re-runs when the theme flips. const parseCache = new Map(); -/** Render a Rich string to HTML for the given theme. */ -export function richToHtml(src: string, dark: boolean): string { +function parsedCached(src: string): RichToken[] { let tokens = parseCache.get(src); if (!tokens) { tokens = parseRich(src); parseCache.set(src, tokens); } - return tokensToHtml(tokens, dark); + return tokens; +} + +/** Render a Rich string to HTML for the given theme. */ +export function richToHtml(src: string, dark: boolean): string { + return tokensToHtml(parsedCached(src), dark); +} + +// ---------- The print serializer ---------- +// Same token AST, emitted as LaTeX. Print always takes the light branch of +// theme spans. Text is escaped for TeX; a few symbols the app writes as +// Unicode map to robust TeX commands so no font gamble is needed. + +// One escaping pass over the raw text, character by character, so no rule +// can mangle another rule's output. TeX specials are escaped; the Unicode +// symbols the app's prose uses map to robust commands (no font gamble). +const TEX_CHAR: Record = { + '\\': '\\textbackslash{}', + '%': '\\%', + '#': '\\#', + '&': '\\&', + _: '\\_', + $: '\\$', + '~': '\\textasciitilde{}', + '^': '\\textasciicircum{}', + '{': '\\{', + '}': '\\}', + '✓': '\\checkmark{}', + '✗': '\\(\\times\\)', + '∞': '\\(\\infty\\)', + '½': '\\(\\tfrac{1}{2}\\)', + '√': '\\(\\surd\\)', + '×': '\\(\\times\\)', + '→': '\\(\\to\\)', + '←': '\\(\\leftarrow\\)', + '∇': '\\(\\nabla\\)', + ℒ: '\\(\\mathcal{L}\\)', + '‖': '\\(\\Vert\\)', + γ: '\\(\\gamma\\)', + κ: '\\(\\kappa\\)', + λ: '\\(\\lambda\\)', + μ: '\\(\\mu\\)', + α: '\\(\\alpha\\)', + β: '\\(\\beta\\)', + θ: '\\(\\theta\\)', + '∂': '\\(\\partial\\)', + '▸': '\\(\\triangleright\\)', + '⇧': 'Shift', + // the zoo cards' tiny formulas are Unicode prose; keep them printable + '−': '\\(-\\)', + '²': '\\(^2\\)', + '³': '\\(^3\\)', + ⁿ: '\\(^n\\)', + '±': '\\(\\pm\\)', + σ: '\\(\\sigma\\)', + τ: '\\(\\tau\\)', + Λ: '\\(\\Lambda\\)', + '↔': '\\(\\leftrightarrow\\)', + '∿': '\\(\\sim\\)', + '╱': '/', + '≈': '\\(\\approx\\)', + '≤': '\\(\\le\\)', + '∈': '\\(\\in\\)' +}; + +const TEX_CHAR_RE = new RegExp( + `[${Object.keys(TEX_CHAR) + .map(c => c.replace(/[\\^\]\[-]/g, m => '\\' + m)) + .join('')}]`, + 'g' +); + +export function escapeTex(s: string): string { + return s.replace(TEX_CHAR_RE, ch => TEX_CHAR[ch]); +} + +function tokensToTex(tokens: RichToken[]): string { + let tex = ''; + for (const tk of tokens) { + switch (tk.t) { + case 'text': + tex += escapeTex(tk.s); + break; + case 'math': + tex += `\\(${tk.tex}\\)`; + break; + case 'strong': + tex += `\\textbf{${tokensToTex(tk.children)}}`; + break; + case 'em': + tex += `\\emph{${tokensToTex(tk.children)}}`; + break; + case 'g': + // The green knob accent has no print channel; italics carry it. + tex += `\\emph{${tokensToTex(tk.children)}}`; + break; + case 'ink': + tex += `\\textbf{${tokensToTex(tk.children)}}`; + break; + case 'theme': + if (tk.mode === 'light') tex += tokensToTex(tk.children); + break; + } + } + return tex; +} + +/** Render a Rich string as LaTeX (print takes the light theme branch). */ +export function richToTex(src: string): string { + return tokensToTex(parsedCached(src)); }