Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 122 additions & 15 deletions core/codeRenderer/CodeRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@
* we rarely ever need syntax highlighting outside of
* creating a render of it.
*/
import {
transformerMetaHighlight,
transformerNotationDiff,
transformerNotationFocus,
transformerNotationHighlight,
} from "@shikijs/transformers";
import { JSDOM } from "jsdom";
import { BundledTheme, codeToHtml, getSingletonHighlighter } from "shiki";
import {
BundledLanguage,
BundledTheme,
getSingletonHighlighter,
Highlighter,
} from "shiki";
import { escapeForSVG, kebabOfStr } from "../util/text";

interface CodeRendererOptions {
Expand Down Expand Up @@ -42,6 +53,8 @@ export class CodeRenderer {
private currentTheme: string = "dark-plus";
private editorBackground: string = "#000000";
private editorForeground: string = "#FFFFFF";
private editorLineHighlight: string = "#000000";
private highlighter: Highlighter | null = null;

private constructor() {}

Expand All @@ -62,13 +75,17 @@ export class CodeRenderer {
? "dark-plus"
: kebabOfStr(themeName);

const highlighter = await getSingletonHighlighter({
this.highlighter = await getSingletonHighlighter({
langs: ["typescript"],
themes: [this.currentTheme],
});

const th = highlighter.getTheme(this.currentTheme);
const th = this.highlighter.getTheme(this.currentTheme);

this.editorBackground = th.bg;
this.editorForeground = th.fg;
this.editorLineHighlight =
th.colors!["editor.lineHighlightBackground"] ?? "#000000";
} else {
this.currentTheme = "dark-plus";
}
Expand Down Expand Up @@ -148,10 +165,29 @@ export class CodeRenderer {
async highlightCode(
code: string,
language: string = "javascript",
currLineOffsetFromTop: number,
): Promise<string> {
return await codeToHtml(code, {
const annotatedCode = code
.split("\n")
.map((line, i) =>
i === currLineOffsetFromTop
? line + " \/\/ \[\!code highlight\]"
: line,
)
.join("\n");

await this.highlighter!.loadLanguage(language as BundledLanguage);

return this.highlighter!.codeToHtml(annotatedCode, {
lang: language,
theme: this.currentTheme,
transformers: [
// transformerColorizedBrackets(),
transformerMetaHighlight(),
transformerNotationHighlight(),
transformerNotationDiff(),
transformerNotationFocus(),
],
});
}

Expand All @@ -163,23 +199,40 @@ export class CodeRenderer {
dimensions: Dimensions,
lineHeight: number,
options: ConversionOptions,
currLineOffsetFromTop: number,
): Promise<Buffer> {
const highlightedCodeHtml = await this.highlightCode(code, language);
const strokeWidth = 1;
const highlightedCodeHtml = await this.highlightCode(
code,
language,
currLineOffsetFromTop,
);
// console.log(highlightedCodeHtml);

const guts = this.convertShikiHtmlToSvgGut(
const { guts, lineBackgrounds } = this.convertShikiHtmlToSvgGut(
highlightedCodeHtml,
fontSize,
fontFamily,
lineHeight,
dimensions,
);
const backgroundColor = this.getBackgroundColor(highlightedCodeHtml);

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${dimensions.width}" height="${dimensions.height}">
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${dimensions.width}" height="${dimensions.height}" shape-rendering="crispEdges">
<style>
:root {
--purple: rgb(112, 114, 209);
--green: rgb(136, 194, 163);
--blue: rgb(107, 166, 205);
}
</style>
<g>
<rect width="${dimensions.width}" height="${dimensions.height}" fill="${backgroundColor}" stroke="${this.editorForeground}" stroke-width="1" />
<rect x="0" y="0" rx="10" ry="10" width="${dimensions.width}" height="${dimensions.height}" fill="${this.editorBackground}" shape-rendering="crispEdges" />
${lineBackgrounds}
${guts}
</g>
</svg>`;
console.log(svg);

return Buffer.from(svg, "utf8");
}
Expand All @@ -189,7 +242,8 @@ export class CodeRenderer {
fontSize: number,
fontFamily: string,
lineHeight: number,
): string {
dimensions: Dimensions,
): { guts: string; lineBackgrounds: string } {
const dom = new JSDOM(shikiHtml);
const document = dom.window.document;

Expand All @@ -204,19 +258,70 @@ export class CodeRenderer {
const el = node as HTMLElement;
const style = el.getAttribute("style") || "";
const colorMatch = style.match(/color:\s*(#[0-9a-fA-F]{6})/);
const fill = colorMatch ? ` fill="${colorMatch[1]}"` : "";
const classes = el.getAttribute("class") || "";
let fill = colorMatch ? ` fill="${colorMatch[1]}"` : "";
if (classes.includes("highlighted")) {
fill = ` fill="${this.editorLineHighlight}"`;
}
const content = el.textContent || "";
return `<tspan xml:space="preserve"${fill}>${escapeForSVG(content)}</tspan>`;
})
.join("");

const y = (index + 1) * lineHeight;
return `<text x="0" y="${y}" font-family="${fontFamily}" font-size="${fontSize.toString()}" xml:space="preserve">${spans}</text>`;
// Typography notes:
// Each line of code is a <text> inside a <rect>.
// Math becomes interesting here; the y value is actually aligned to the topmost border.
// So y = 0 will have the rect be flush with the top border.
// More importantly, text will also be positioned that way.
// Since y = 0 is the axis the text will align itself to, the default settings will actually have the text sitting "on top of" the y = 0 axis, which effectively shifts them up.
// To prevent this, we want the alignment axis to be at the middle of each rect, and have the text align itself vertically to the center (skwered by the axis).
// The first step is to add lineHeight / 2 to move the axis down.
// The second step is to add 'dominant-baseline="central"' to vertically center the text.
// Note that we choose "central" over "middle". "middle" will center the text too perfectly, which is actually undesirable!
const y = index * lineHeight + lineHeight / 2;
return `<text x="0" y="${y}" font-family="${fontFamily}" font-size="${fontSize.toString()}" xml:space="preserve" dominant-baseline="central" shape-rendering="crispEdges">${spans}</text>`;
});

return `
${svgLines.join("\n")}
`.trim();
const lineBackgrounds = lines
.map((line, index) => {
const classes = line?.getAttribute("class") || "";
const bgColor = classes.includes("highlighted")
? this.editorLineHighlight
: this.editorBackground;
const y = index * lineHeight;
const isFirst = index === 0;
const isLast = index === lines.length - 1;
const radius = 10;
// SVG notes:
// By default SVGs have anti-aliasing on.
// This is undesirable in our case because pixel-perfect alignment of these rectangles will introduce thin gaps.
// Turning it off with 'shape-rendering="crispEdges"' solves the issue.
return isFirst
? `<path d="M ${0} ${y + lineHeight}
L ${0} ${y + radius}
Q ${0} ${y} ${radius} ${y}
L ${dimensions.width - radius} ${y}
Q ${dimensions.width} ${y} ${dimensions.width} ${y + radius}
L ${dimensions.width} ${y + lineHeight}
Z"
fill="${bgColor}" />`
: isLast
? `<path d="M ${0} ${y}
L ${0} ${y + lineHeight - radius}
Q ${0} ${y + lineHeight} ${radius} ${y + lineHeight}
L ${dimensions.width - radius} ${y + lineHeight}
Q ${dimensions.width} ${y + lineHeight} ${dimensions.width} ${y + lineHeight - 10}
L ${dimensions.width} ${y}
Z"
fill="${bgColor}" />`
: `<rect x="0" y="${y}" rx="${radius}" ry="${radius}" width="100%" height="${lineHeight}" fill="${bgColor}" shape-rendering="crispEdges" />`;
})
.join("\n");

return {
guts: svgLines.join("\n"),
lineBackgrounds,
};
}

getBackgroundColor(shikiHtml: string): string {
Expand Down Expand Up @@ -244,6 +349,7 @@ export class CodeRenderer {
dimensions: Dimensions,
lineHeight: number,
options: ConversionOptions,
currLineOffsetFromTop: number,
): Promise<DataUri> {
switch (options.imageType) {
// case "png":
Expand All @@ -265,6 +371,7 @@ export class CodeRenderer {
dimensions,
lineHeight,
options,
currLineOffsetFromTop,
);
return `data:image/svg+xml;base64,${svgBuffer.toString("base64")}`;
}
Expand Down
87 changes: 55 additions & 32 deletions core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"@babel/preset-env": "^7.24.7",
"@biomejs/biome": "1.6.4",
"@google/generative-ai": "^0.11.4",
"@shikijs/colorized-brackets": "^3.7.0",
"@shikijs/transformers": "^3.7.0",
"@types/diff": "^7.0.1",
"@types/follow-redirects": "^1.14.4",
"@types/jest": "^29.5.12",
Expand Down
Loading
Loading