8
8
* we rarely ever need syntax highlighting outside of
9
9
* creating a render of it.
10
10
*/
11
+ import {
12
+ transformerMetaHighlight ,
13
+ transformerNotationDiff ,
14
+ transformerNotationFocus ,
15
+ transformerNotationHighlight ,
16
+ } from "@shikijs/transformers" ;
11
17
import { JSDOM } from "jsdom" ;
12
- import { BundledTheme , codeToHtml , getSingletonHighlighter } from "shiki" ;
18
+ import {
19
+ BundledLanguage ,
20
+ BundledTheme ,
21
+ getSingletonHighlighter ,
22
+ Highlighter ,
23
+ } from "shiki" ;
13
24
import { escapeForSVG , kebabOfStr } from "../util/text" ;
14
25
15
26
interface CodeRendererOptions {
@@ -42,6 +53,8 @@ export class CodeRenderer {
42
53
private currentTheme : string = "dark-plus" ;
43
54
private editorBackground : string = "#000000" ;
44
55
private editorForeground : string = "#FFFFFF" ;
56
+ private editorLineHighlight : string = "#000000" ;
57
+ private highlighter : Highlighter | null = null ;
45
58
46
59
private constructor ( ) { }
47
60
@@ -62,13 +75,17 @@ export class CodeRenderer {
62
75
? "dark-plus"
63
76
: kebabOfStr ( themeName ) ;
64
77
65
- const highlighter = await getSingletonHighlighter ( {
78
+ this . highlighter = await getSingletonHighlighter ( {
79
+ langs : [ "typescript" ] ,
66
80
themes : [ this . currentTheme ] ,
67
81
} ) ;
68
82
69
- const th = highlighter . getTheme ( this . currentTheme ) ;
83
+ const th = this . highlighter . getTheme ( this . currentTheme ) ;
84
+
70
85
this . editorBackground = th . bg ;
71
86
this . editorForeground = th . fg ;
87
+ this . editorLineHighlight =
88
+ th . colors ! [ "editor.lineHighlightBackground" ] ?? "#000000" ;
72
89
} else {
73
90
this . currentTheme = "dark-plus" ;
74
91
}
@@ -148,10 +165,29 @@ export class CodeRenderer {
148
165
async highlightCode (
149
166
code : string ,
150
167
language : string = "javascript" ,
168
+ currLineOffsetFromTop : number ,
151
169
) : Promise < string > {
152
- return await codeToHtml ( code , {
170
+ const annotatedCode = code
171
+ . split ( "\n" )
172
+ . map ( ( line , i ) =>
173
+ i === currLineOffsetFromTop
174
+ ? line + " \/\/ \[\!code highlight\]"
175
+ : line ,
176
+ )
177
+ . join ( "\n" ) ;
178
+
179
+ await this . highlighter ! . loadLanguage ( language as BundledLanguage ) ;
180
+
181
+ return this . highlighter ! . codeToHtml ( annotatedCode , {
153
182
lang : language ,
154
183
theme : this . currentTheme ,
184
+ transformers : [
185
+ // transformerColorizedBrackets(),
186
+ transformerMetaHighlight ( ) ,
187
+ transformerNotationHighlight ( ) ,
188
+ transformerNotationDiff ( ) ,
189
+ transformerNotationFocus ( ) ,
190
+ ] ,
155
191
} ) ;
156
192
}
157
193
@@ -163,23 +199,40 @@ export class CodeRenderer {
163
199
dimensions : Dimensions ,
164
200
lineHeight : number ,
165
201
options : ConversionOptions ,
202
+ currLineOffsetFromTop : number ,
166
203
) : Promise < Buffer > {
167
- const highlightedCodeHtml = await this . highlightCode ( code , language ) ;
204
+ const strokeWidth = 1 ;
205
+ const highlightedCodeHtml = await this . highlightCode (
206
+ code ,
207
+ language ,
208
+ currLineOffsetFromTop ,
209
+ ) ;
210
+ // console.log(highlightedCodeHtml);
168
211
169
- const guts = this . convertShikiHtmlToSvgGut (
212
+ const { guts, lineBackgrounds } = this . convertShikiHtmlToSvgGut (
170
213
highlightedCodeHtml ,
171
214
fontSize ,
172
215
fontFamily ,
173
216
lineHeight ,
217
+ dimensions ,
174
218
) ;
175
219
const backgroundColor = this . getBackgroundColor ( highlightedCodeHtml ) ;
176
220
177
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ dimensions . width } " height="${ dimensions . height } ">
221
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ dimensions . width } " height="${ dimensions . height } " shape-rendering="crispEdges">
222
+ <style>
223
+ :root {
224
+ --purple: rgb(112, 114, 209);
225
+ --green: rgb(136, 194, 163);
226
+ --blue: rgb(107, 166, 205);
227
+ }
228
+ </style>
178
229
<g>
179
- <rect width="${ dimensions . width } " height="${ dimensions . height } " fill="${ backgroundColor } " stroke="${ this . editorForeground } " stroke-width="1" />
230
+ <rect x="0" y="0" rx="10" ry="10" width="${ dimensions . width } " height="${ dimensions . height } " fill="${ this . editorBackground } " shape-rendering="crispEdges" />
231
+ ${ lineBackgrounds }
180
232
${ guts }
181
233
</g>
182
234
</svg>` ;
235
+ console . log ( svg ) ;
183
236
184
237
return Buffer . from ( svg , "utf8" ) ;
185
238
}
@@ -189,7 +242,8 @@ export class CodeRenderer {
189
242
fontSize : number ,
190
243
fontFamily : string ,
191
244
lineHeight : number ,
192
- ) : string {
245
+ dimensions : Dimensions ,
246
+ ) : { guts : string ; lineBackgrounds : string } {
193
247
const dom = new JSDOM ( shikiHtml ) ;
194
248
const document = dom . window . document ;
195
249
@@ -204,19 +258,70 @@ export class CodeRenderer {
204
258
const el = node as HTMLElement ;
205
259
const style = el . getAttribute ( "style" ) || "" ;
206
260
const colorMatch = style . match ( / c o l o r : \s * ( # [ 0 - 9 a - f A - F ] { 6 } ) / ) ;
207
- const fill = colorMatch ? ` fill="${ colorMatch [ 1 ] } "` : "" ;
261
+ const classes = el . getAttribute ( "class" ) || "" ;
262
+ let fill = colorMatch ? ` fill="${ colorMatch [ 1 ] } "` : "" ;
263
+ if ( classes . includes ( "highlighted" ) ) {
264
+ fill = ` fill="${ this . editorLineHighlight } "` ;
265
+ }
208
266
const content = el . textContent || "" ;
209
267
return `<tspan xml:space="preserve"${ fill } >${ escapeForSVG ( content ) } </tspan>` ;
210
268
} )
211
269
. join ( "" ) ;
212
270
213
- const y = ( index + 1 ) * lineHeight ;
214
- return `<text x="0" y="${ y } " font-family="${ fontFamily } " font-size="${ fontSize . toString ( ) } " xml:space="preserve">${ spans } </text>` ;
271
+ // Typography notes:
272
+ // Each line of code is a <text> inside a <rect>.
273
+ // Math becomes interesting here; the y value is actually aligned to the topmost border.
274
+ // So y = 0 will have the rect be flush with the top border.
275
+ // More importantly, text will also be positioned that way.
276
+ // 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.
277
+ // 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).
278
+ // The first step is to add lineHeight / 2 to move the axis down.
279
+ // The second step is to add 'dominant-baseline="central"' to vertically center the text.
280
+ // Note that we choose "central" over "middle". "middle" will center the text too perfectly, which is actually undesirable!
281
+ const y = index * lineHeight + lineHeight / 2 ;
282
+ return `<text x="0" y="${ y } " font-family="${ fontFamily } " font-size="${ fontSize . toString ( ) } " xml:space="preserve" dominant-baseline="central" shape-rendering="crispEdges">${ spans } </text>` ;
215
283
} ) ;
216
284
217
- return `
218
- ${ svgLines . join ( "\n" ) }
219
- ` . trim ( ) ;
285
+ const lineBackgrounds = lines
286
+ . map ( ( line , index ) => {
287
+ const classes = line ?. getAttribute ( "class" ) || "" ;
288
+ const bgColor = classes . includes ( "highlighted" )
289
+ ? this . editorLineHighlight
290
+ : this . editorBackground ;
291
+ const y = index * lineHeight ;
292
+ const isFirst = index === 0 ;
293
+ const isLast = index === lines . length - 1 ;
294
+ const radius = 10 ;
295
+ // SVG notes:
296
+ // By default SVGs have anti-aliasing on.
297
+ // This is undesirable in our case because pixel-perfect alignment of these rectangles will introduce thin gaps.
298
+ // Turning it off with 'shape-rendering="crispEdges"' solves the issue.
299
+ return isFirst
300
+ ? `<path d="M ${ 0 } ${ y + lineHeight }
301
+ L ${ 0 } ${ y + radius }
302
+ Q ${ 0 } ${ y } ${ radius } ${ y }
303
+ L ${ dimensions . width - radius } ${ y }
304
+ Q ${ dimensions . width } ${ y } ${ dimensions . width } ${ y + radius }
305
+ L ${ dimensions . width } ${ y + lineHeight }
306
+ Z"
307
+ fill="${ bgColor } " />`
308
+ : isLast
309
+ ? `<path d="M ${ 0 } ${ y }
310
+ L ${ 0 } ${ y + lineHeight - radius }
311
+ Q ${ 0 } ${ y + lineHeight } ${ radius } ${ y + lineHeight }
312
+ L ${ dimensions . width - radius } ${ y + lineHeight }
313
+ Q ${ dimensions . width } ${ y + lineHeight } ${ dimensions . width } ${ y + lineHeight - 10 }
314
+ L ${ dimensions . width } ${ y }
315
+ Z"
316
+ fill="${ bgColor } " />`
317
+ : `<rect x="0" y="${ y } " rx="${ radius } " ry="${ radius } " width="100%" height="${ lineHeight } " fill="${ bgColor } " shape-rendering="crispEdges" />` ;
318
+ } )
319
+ . join ( "\n" ) ;
320
+
321
+ return {
322
+ guts : svgLines . join ( "\n" ) ,
323
+ lineBackgrounds,
324
+ } ;
220
325
}
221
326
222
327
getBackgroundColor ( shikiHtml : string ) : string {
@@ -244,6 +349,7 @@ export class CodeRenderer {
244
349
dimensions : Dimensions ,
245
350
lineHeight : number ,
246
351
options : ConversionOptions ,
352
+ currLineOffsetFromTop : number ,
247
353
) : Promise < DataUri > {
248
354
switch ( options . imageType ) {
249
355
// case "png":
@@ -265,6 +371,7 @@ export class CodeRenderer {
265
371
dimensions ,
266
372
lineHeight ,
267
373
options ,
374
+ currLineOffsetFromTop ,
268
375
) ;
269
376
return `data:image/svg+xml;base64,${ svgBuffer . toString ( "base64" ) } ` ;
270
377
}
0 commit comments