diff --git a/README.md b/README.md index 3067199..5713ddb 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,21 @@ npm i shiki-renderer-canvas ## Usage +### Custom Fonts + +You can use any font you want by passing the `fontFamily` config. + +Keep in mind that the font must be accesible to the canvas instance. + +For the browser, the font should be locally installed, and for Node.js, the font should be +[registered](https://github.com/Automattic/node-canvas/#registerfont) in `node-canvas` **before calling `createCanvas`**. + +An example of how to register a font in Node.js is available at the [generate-sample.ts](./generate-sample.ts) file. + +> ⚠️ Important ⚠️: Although `node-canvas` docs say that `registerFont` is only necessary for fonts not installed in +> the system, I found it to be quite hard to get those to work. The best approach for custom fonts in Node.js is to +> always register them with `node-canvas`. + ### Node.js ```typescript diff --git a/generate-sample.ts b/generate-sample.ts new file mode 100644 index 0000000..3b3b1b5 --- /dev/null +++ b/generate-sample.ts @@ -0,0 +1,35 @@ +import fs from 'fs/promises' +import path from 'path' +import { getHighlighter } from 'shiki' +import { createCanvas, registerFont } from 'canvas' +import { getCanvasRenderer } from './src' + +const render = async (fontFamily?: string) => { + const code = await fs.readFile(path.join(__dirname, 'src/index.ts'), 'utf8') + + const highlighter = await getHighlighter({ + langs: ['typescript'], + theme: 'dracula' + }) + + const canvas = createCanvas(1, 1) + const renderer = getCanvasRenderer(canvas, { + font: { family: fontFamily } + }) + + const tokens = highlighter.codeToThemedTokens(code, 'typescript') + + const image = await renderer.renderToCanvas(tokens) + await fs.writeFile(path.join(__dirname, 'sample.png'), image.toBuffer()) +} + +if (process.argv.length > 2) { + const fontPath = path.resolve(process.argv[2]) + const fontName = path.basename(fontPath, path.extname(fontPath)) + + console.log(`Registering font at ${fontPath} as ${fontName}`) + registerFont(fontPath, { family: fontName }) + render(fontName) +} else { + render() +} diff --git a/package.json b/package.json index 8ffacc5..7ca0f83 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "dist/index.js", "scripts": { "test": "mocha -b -r ts-node/register test/**/*.ts", + "sample": "ts-node generate-sample.ts", "build": "tsc", "clean": "rm -rf dist tsconfig.tsbuildinfo", "build:clean": "npm run clean && npm run build", @@ -80,4 +81,4 @@ "dependencies": { "lodash.merge": "^4.6.2" } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 330b13b..634102e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,18 +5,9 @@ import { DeepPartial } from './DeepPartial' import { getLineHeight, getLongestLineLength, getTextHeight } from './math' import { drawBoundingBox } from './pencil' -type Options = { - fontSize: number - padding: { - vertical: number - horizontal: number - } - backgroundColor: string - drawPaddingLines: boolean -} - -const DEFAULT_OPTIONS: Options = { +const DEFAULT_OPTIONS = { fontSize: 20, + fontFamily: 'monospace', padding: { vertical: 20, horizontal: 20 @@ -25,23 +16,30 @@ const DEFAULT_OPTIONS: Options = { drawPaddingLines: false } +export type CanvasRendererOptions = typeof DEFAULT_OPTIONS + export function getCanvasRenderer( canvas: TCanvas, - options: DeepPartial = {} + options: DeepPartial = {} ) { const config = merge(DEFAULT_OPTIONS, options) - const { fontSize, padding, backgroundColor } = config + const { fontSize, fontFamily, padding, backgroundColor } = config const ctx = canvas.getContext('2d') as CanvasRenderingContext2D if (!ctx) throw new Error('no canvas context') return { renderToCanvas(tokens: shiki.IThemedToken[][]) { - const longestLineLength = getLongestLineLength(ctx, tokens, fontSize) + const longestLineLength = getLongestLineLength( + ctx, + tokens, + fontSize, + fontFamily + ) canvas.width = longestLineLength + padding.horizontal * 2 canvas.height = - getTextHeight(ctx, fontSize, tokens) + padding.vertical * 2 + getTextHeight(ctx, fontSize, fontFamily, tokens) + padding.vertical * 2 ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) @@ -54,7 +52,7 @@ export function getCanvasRenderer( let x = padding.horizontal for (const token of line) { - ctx.font = `${fontSize}px monospace` + ctx.font = `${fontSize}px ${fontFamily}` ctx.textBaseline = 'top' ctx.fillStyle = token.color || '#fff' ctx.fillText(token.content, x, y) @@ -62,7 +60,7 @@ export function getCanvasRenderer( x += ctx.measureText(token.content).width } - y += getLineHeight(ctx, fontSize, line) + y += getLineHeight(ctx, fontSize, fontFamily, line) } return canvas diff --git a/src/math.ts b/src/math.ts index e026f93..5ae07ca 100644 --- a/src/math.ts +++ b/src/math.ts @@ -10,11 +10,12 @@ export function getAbsoluteWidth(metrics: TextMetrics) { export function getLongestLineLength( ctx: CanvasRenderingContext2D, lines: shiki.IThemedToken[][], - fontSize: number + fontSize: number, + fontFamily: string ) { const previousFont = `${ctx.font}` const previousTextBaseline = `${ctx.textBaseline}` as CanvasTextBaseline - ctx.font = `${fontSize}px monospace` + ctx.font = `${fontSize}px ${fontFamily}` ctx.textBaseline = 'top' const textLineLengths = lines @@ -30,13 +31,14 @@ export function getLongestLineLength( export function getLineHeight( ctx: CanvasRenderingContext2D, fontSize: number, + fontFamily: string, text: string | shiki.IThemedToken[] = 'M' ) { const previousFont = `${ctx.font}` const previousTextBaseline = `${ctx.textBaseline}` as CanvasTextBaseline const line = Array.isArray(text) ? text.map((t) => t.content).join('') : text - ctx.font = `${fontSize}px monospace` + ctx.font = `${fontSize}px ${fontFamily}` ctx.textBaseline = 'top' const measurements = ctx.measureText(line) @@ -52,10 +54,11 @@ export function getLineHeight( export function getTextHeight( ctx: CanvasRenderingContext2D, fontSize: number, + fontFamily: string, lines: shiki.IThemedToken[][] ) { return lines.reduce((result, line) => { const lineContent = line.map((token) => token.content).join('') - return result + getLineHeight(ctx, fontSize, lineContent) + return result + getLineHeight(ctx, fontSize, fontFamily, lineContent) }, 0) }