Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

shiki-renderer-svg support #681

Closed
wants to merge 11 commits into from
Closed
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
1 change: 1 addition & 0 deletions packages/shiki-renderer-svg/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.html
27 changes: 27 additions & 0 deletions packages/shiki-renderer-svg/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "shiki-renderer-svg",
"type": "module",
"version": "1.0.0",
"description": "SVG code block renderer based on shiki",
"author": "",
"license": "MIT",
"keywords": [
"shiki-renderer-svg"
],
"main": "dist/index.js",
"module": "dist/index.mjs",
"browser": "dist/index.browser.mjs",
"unpkg": "dist/index.iife.min.js",
"jsdelivr": "dist/index.iife.min.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rollup -c"
},
"dependencies": {
"playwright": "^1.44.0",
"shiki": "workspace:*"
}
}
93 changes: 93 additions & 0 deletions packages/shiki-renderer-svg/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Re: https://github.com/rollup/plugins/issues/1366
import { fileURLToPath } from 'node:url'

import { nodeResolve } from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import dts from 'rollup-plugin-dts'
import esbuild from 'rollup-plugin-esbuild'
import terser from '@rollup/plugin-terser'
import rollupReplace from '@rollup/plugin-replace'

const __filename = fileURLToPath(import.meta.url)
globalThis.__filename = __filename

function replace(opts) {
return rollupReplace({
...opts,
preventAssignment: true,
})
}

const external = ['shiki', 'playwright']
const globals = {
shiki: 'shiki',
}

export default [
{
input: 'src/index.ts',
external,
output: [
{
file: 'dist/index.js',
format: 'cjs',
},
{
file: 'dist/index.mjs',
format: 'esm',
},
],
plugins: [
replace({
__BROWSER__: JSON.stringify(false),
}),
esbuild(),
nodeResolve(),
commonjs(),
],
},
{
input: 'src/index.ts',
external,
output: [
{
file: 'dist/index.iife.js',
format: 'iife',
extend: true,
name: 'shiki',
globals,
},
{
file: 'dist/index.iife.min.js',
format: 'iife',
extend: true,
name: 'shiki',
plugins: [terser()],
globals,
},
{
file: 'dist/index.browser.mjs',
format: 'esm',
globals,
},
],
plugins: [
replace({
__BROWSER__: JSON.stringify(true),
}),
esbuild(),
nodeResolve(),
commonjs(),
],
},
{
input: 'src/index.ts',
output: [
{
file: 'dist/index.d.ts',
format: 'es',
},
],
plugins: [dts()],
},
]
1 change: 1 addition & 0 deletions packages/shiki-renderer-svg/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare let __BROWSER__: boolean
222 changes: 222 additions & 0 deletions packages/shiki-renderer-svg/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import type { ThemedToken } from 'shiki'
import { measureFont } from './measureFont'

interface RenderOptions {
/**
* The rendered font family.
*
* @default '"Lucida Console", Courier, monospace'
*/
fontFamily?: string

/**
* The rendered font size.
*
* @default 16
*/
fontSize?: number

/**
* The line height is caculated based on the font height.
* The lineHeightRatio is the ratio of line height to font height(font size).
*
* @default 1.5
*/
lineHeightRatio?: number

/**
* The svg background color.
*
* @default '#eee'
*/
backgroundColor?: string

/**
* The svg border-radius.
*
* @default 0
*/
borderRadius?: number

/**
* The text background color when you select text.
*
* @default '#b4d5ea'
*/
selectionbgColor?: string

/**
* The font color when you select text.
*
* @default ''
*/
selectionColor?: string

/**
* The cursor style when the mouse is placed on the svg text.
*
* @default 'default'
*/
cursor?: string

/**
* Svg opacity.
*
* @default 1
*/
opacity?: number

/**
* Used for measuring the width and height of the text
* when not in browser environment.
*
* @default ''
*/
remoteFontCSSURL?: string
}

type RequiredRenderOptions = Required<RenderOptions>
const defaultRenderOptions: RequiredRenderOptions = {
fontFamily: '"Lucida Console", Courier, monospace',
fontSize: 16,
lineHeightRatio: 1.5,
backgroundColor: '#eee',
borderRadius: 0,
opacity: 1,
remoteFontCSSURL: '',
cursor: 'default',
selectionColor: '',
selectionbgColor: '#b4d5ea',
}

export async function getSVGRenderer(renderOptions?: RenderOptions) {
const options = { ...defaultRenderOptions, ...renderOptions }

const svgId = getId()
const styleStr = generateStyle(svgId, options)
const {
fontSize,
fontFamily,
remoteFontCSSURL,
lineHeightRatio,
} = options

let { width: fontWidth, height: fontHeight } = await measureFont(
fontSize,
fontFamily,
remoteFontCSSURL,
)
fontHeight *= (lineHeightRatio / 1.5)

return {
renderToSVG(tokenLines: ThemedToken[][]) {
const [svgWidth, svgHeight] = getAdaptiveWidthAndHeight(
tokenLines,
fontWidth,
fontHeight,
)

let svg = `<svg ${svgId} viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" `
+ `height="${svgHeight}" font-size="${fontSize}px" xmlns="http://www.w3.org/2000/svg">`
svg += styleStr

const x = Math.floor(fontWidth / 2)
let y = Math.floor(fontHeight / 4) + fontHeight
for (const line of tokenLines) {
if (line.length === 0) {
svg += `<text x="${x}" y="${y}"><tspan fill="#000">&nbsp;</tspan></text>`
}
else {
svg += `<text x="${x}" y="${y}">`

for (const token of line) {
svg += `<tspan fill="${token.color}">${decodeContent(
token.content!,
)}</tspan>`
}

svg += '</text>'
}
y += fontHeight
}

svg += '</svg>'

return svg
},
}
}

function generateStyle(svgId: string, options: RequiredRenderOptions) {
const {
fontFamily,
backgroundColor,
borderRadius,
cursor,
opacity,
selectionbgColor,
selectionColor,
} = options

// svg css
let svgStyle = `svg[${svgId}]`
svgStyle += '{'
svgStyle += (`font-family:${fontFamily};background-color:${backgroundColor};cursor:${cursor};`)
if (opacity < 1 && opacity >= 0)
svgStyle += `opacity:${opacity};`

if (borderRadius > 0)
svgStyle += `border-radius:${borderRadius}px;`

svgStyle += `}`

// selection css
let svgStyleSelection = `svg[${svgId}] tspan::selection`
svgStyleSelection += '{'
svgStyleSelection += `background-color:${selectionbgColor};`
if (selectionColor.length > 0)
svgStyleSelection += `fill:${selectionColor};`

svgStyleSelection += `}`

return `<style>${svgStyle + svgStyleSelection}</style>`
}

function getId() {
return `data-svg-${Math.random().toString(36).substring(2, 9)}`
}

const contentMap = new Map<string, string>([
[' ', '&nbsp;'],
['<', '&lt;'],
['>', '&gt;'],
['&', '&amp;'],
['"', '&quot;'],
['\'', '&#39;'],
])

function decodeContent(str: string) {
let res: string = ''
for (let i = 0; i < str.length; i++) {
if (contentMap.has(str[i]))
res += contentMap.get(str[i])!
else
res += str[i]
}
return res
}

function getAdaptiveWidthAndHeight(
tokenLines: ThemedToken[][],
fontWidth: number,
fontHeight: number,
) {
const height = (tokenLines.length + 1) * fontHeight
const maxCharNum = Math.max(
...tokenLines.map(line =>
line.reduce((acc, val) => acc + val.content!.length, 0),
),
)
const width = (maxCharNum + 1) * fontWidth
return [width.toFixed(2), height.toFixed(2)]
}
Loading
Loading