DjDeveloperr / deno-canvas

Canvas API for Deno, ported from canvaskit-wasm (Skia).
https://deno.land/x/canvas
MIT License
190 stars 18 forks source link

[feature request] loading custom font support #17

Open andykais opened 3 years ago

andykais commented 3 years ago

deno-canvas supports writing text to a canvas:

import { createCanvas } from 'https://deno.land/x/canvas/mod.ts'

const canvas = createCanvas(500,600)
const ctx = canvas.getContext('2d')
ctx.fillStyle='red'
ctx.fillText(50,50,"Hello World")
await Deno.writeFile("image.png", canvas.toBuffer());

this is currently only limited to fonts your system knows about (it might even be more limited than that). Canvas kit has an api for loading custom fonts https://skia.org/docs/user/modules/quickstart/#text-shaping. Itd be great if deno-canvas supported loading custom fonts. I think pulling in the whole paragraph builder api might be substantial, but all I would personally be interested in is mirroring the browser's canvas apis with the addition of being able to load custom fonts. E.g.

import { createCanvas, registerFont } from 'https://deno.land/x/canvas/mod.ts'

const canvas = createCanvas(500,600)
const ctx = canvas.getContext('2d')
await registerFont({
  font_family: 'Comic Sans',
  // extra fancy would be supporting a url here (e.g. file:///my-fonts/comic-sans.ttf or any web url)
  src: './my-fonts/comic-sans.ttf'
})
ctx.font = 'Comic Sans'
ctx.fillStyle='red'
ctx.fillText(50,50,"Hello World")
await Deno.writeFile("image.png", canvas.toBuffer());

the registerFont method is very similar to the css interface for loading fonts:

@font-face {
    font-family: 'KulminoituvaRegular';
    src: url('http://www.miketaylr.com/f/kulminoituva.ttf');
}
andykais commented 3 years ago

[edit] it seems like ctx.font = is not a supported api at all in the current version. Setting font to anything, including fonts available to the system looks like:

> ctx.font = 'Roboto'
Uncaught TypeError: Cannot read property 'family' of null
    at q.set [as font] (https://deno.land/x/canvas@v1.3.0/src/lib.js:2264:37)
    at <anonymous>:2:10
DjDeveloperr commented 3 years ago

For loading custom fonts, there is canvas.loadFont.

For setting font you'll need to specify size too.

image

And deno-canvas cannot access system fonts either btw, so initially it's limited to one font IIRC.

andykais commented 3 years ago

oh if this is already supported thats fantastic. I am looking at the code now https://github.com/DjDeveloperr/deno-canvas/blob/master/src/types.ts#L1065, what should the descriptors field be?

andykais commented 3 years ago

ah, I figured it out:

canvas.loadFont(fontBuffer, {
  family: 'Comic Sans',
  style: 'normal',
  weight: 'normal',
  variant: 'normal'
})

it probably wouldnt hurt to make the type signatures more specific than Record<string, string>. Perhaps:

type FontDescriptors = {
  /* identifying name of font */
  family: string
  style: 'normal' | 'italic'
  variant: 'normal' | ...
  weight: 'normal' | 'bold' | ...
}
  loadFont(
    bytes: ArrayBuffer | Uint8Array,
    descriptors: FontDescriptors,
  ): void;
andykais commented 3 years ago

seems like the context.measureText results are very inaccurate.

const text = "Hello There"
const family = './fonts/stick-no-bills/StickNoBills-VariableFont_wght.ttf'
// load a font
    const font = await Deno.readFile(family)
    const font_identifier = new Date().toString()
    canvas.loadFont(font, {
      family: font_identifier
    })
    context.font = `${size}px ${font_identifier}`
// get the font measurements
  const metrics = context.measureText(text)
// draw a rect around it
    context.fillStyle = 'white'
    context.fillRect(0, 0, metrics.width, metrics.fontBoundingBoxAscent + metrics.actualBoundingBoxDescent)
// draw the text
    context.fillStyle =  "black"
    context.fillText(text_chunk, 0, 0)

here they are for one font https://fonts.google.com/specimen/Stick+No+Bills

{
  width: 352,
  actualBoundingBoxAscent: 38,
  actualBoundingBoxDescent: 2,
  actualBoundingBoxLeft: 5,
  actualBoundingBoxRight: 357,
  fontBoundingBoxAscent: 47,
  fontBoundingBoxDescent: 15.600000381469727
}

canvas

here they are for another https://www.fontspace.com/category/comic-sans

{
  width: 198,
  actualBoundingBoxAscent: 33,
  actualBoundingBoxDescent: 1,
  actualBoundingBoxLeft: 0,
  actualBoundingBoxRight: 198,
  fontBoundingBoxAscent: 43.75,
  fontBoundingBoxDescent: 13.600000381469727
}

canvas

andykais commented 3 years ago

if this looks like a real issue I can create a separate issue or change the title on this one

andykais commented 3 years ago

Here are some repros. First a jsfiddle which correctly measures the width of text: https://jsfiddle.net/8dk71toq/2/

Screen Shot 2021-09-24 at 1 08 07 PM

and second, deno-canvas incorrectly measuring the same text:

import { createCanvas } from 'https://deno.land/x/canvas@v1.3.0/mod.ts'

const canvas = createCanvas(500, 200)
const context = canvas.getContext('2d')

function draw_text(text: string, x: number, y: number) {
  const metrics = context.measureText(text)
  console.log(metrics)
  context.fillStyle = 'red'
  context.fillRect(
    x,
    y,
    metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight,
    metrics.fontBoundingBoxAscent,
  )

  context.fillStyle = 'black'
  context.fillText(
    text,
    x + metrics.actualBoundingBoxLeft,
    y + metrics.fontBoundingBoxAscent,
  )
}

context.fillStyle = 'white'
context.fillRect(0, 0, canvas.width, canvas.height)
const font_buffer = await Deno.readFile('./fonts/tangerine/Tangerine-Regular.ttf')
canvas.loadFont(font_buffer, {family: 'Tangerine'})
context.font = '50px Tangerine'
draw_text("Hello World", 50, 50)
await Deno.writeFile('canvas.png', canvas.toBuffer())

canvas

both examples use this font https://fonts.google.com/specimen/Tangerine?query=tangerine

andykais commented 3 years ago

some further debugging shows that the builtin font (monospace in the case of my mac) correctly measures the text. 50px monospace is shown below. canvas

loading a different font and specifying it via context.font does produce different measurements, so its clear that canvaskit is reading the new font. It just doesnt quite interpret the sizes correctly. Is this an issue I should move upstream to canvaskit?

DjDeveloperr commented 3 years ago

All values in TextMetrics except width were added with a hacky workaround, so they might not be right. And no, actual canvaskit does not even implement measureText as they just say to use Paragraph API instead. So this is a issue in deno-canvas only.

andykais commented 3 years ago

Got it. Well it is unfortunate that measuring text can't be done with deno canvas. I wanted to build out text captions with borders. I suppose there's an extremely hacky workaround for me where I dump the image data and find the max & min x & y

DjDeveloperr commented 3 years ago

Right.. text rendering as a whole is not-so-good with canvaskit-wasm. Btw, deno-canvas does expose Skia related APIs, such as Paragraph API if that's any helpful for you. I plan on porting Node.js skia-canvas using Deno FFI, if that works out we'd have a more performant and compatible canvas API. Though can't say for sure it'll be a thing, or anytime soon as FFI is limited a lot right now.

andykais commented 3 years ago

It would definitely be cool to see an ffi bridge to skia. For what I need though, if the paragraph api is exposed that would probably get me everything I need. Would you mind showing an example of how to use it in deno canvas (I have seen canvaskits docs but I'm not sure it's the same api here)

DjDeveloperr commented 3 years ago

Skia related APIs are all exposed in a namespace that is default exported from mod.ts.

import Skia from "https://deno.land/x/canvas@v1.3.0/mod.ts";

// Use Skia.Paragraph, Skia.ParagraphBuilder, etc.
// API should be same as canvaskit.

Under the hood, canvaskit polyills HTML Canvas using Skia WASM API. If you don't mind, I'd appreciate a PR to polyfill measureText properly using Paragraph API.

andykais commented 3 years ago

@DjDeveloperr could you give me a primer on contributing to this library? Would it be this file https://github.com/DjDeveloperr/deno-canvas/blob/master/src/lib.js#L2750? Its hard to tell if this is a source file because some of this code looks minified. Could you show where another skia specific method is used as an example?

DjDeveloperr commented 3 years ago

Yeah, in src/lib.js. Right.. it was minified at first. a variable contains everything exported as default in mod.ts, so all Skia APIs. You can use ctrl+f to find implementation of measureText.

andykais commented 3 years ago

@DjDeveloperr its been a minute since I worked on this. Heres a first attempt at measuring text. I can properly measure ascent and descent of text from the baseline. I cant seem to measure left/right properly though. Especially with this font which is extra italicized (this is the "Regular") font. I know if there is a part of the paragraph api I am just missing or what

import CanvasKit, { createCanvas } from "https://deno.land/x/canvas@v1.3.0/mod.ts";

function measureText(text: string, fontInfo: { fontData: Uint8Array, fontSize: number }) {
  // I assume I can find fontInfo somewhere else inside the context class
  const { fontData, fontSize } = fontInfo
  const fontMgr = CanvasKit.FontMgr.FromData(fontData);
  if (fontMgr === null)  throw new Error('idk why but fontMgr is null')
  const paraStyle = new CanvasKit.ParagraphStyle({
    textStyle: {
      color: CanvasKit.BLACK,
      fontFamilies: [fontMgr.getFamilyName(0)],
      fontSize,
    },
  });
  const builder = CanvasKit.ParagraphBuilder.Make(paraStyle, fontMgr);
  builder.addText(text);
  const paragraph = builder.build();
  paragraph.layout(Infinity);
  const left = Math.max(...paragraph.getLineMetrics().map(l => l.left))
  const right = paragraph.getLongestLine() + left
  const ascent = Math.max(...paragraph.getLineMetrics().map(l => l.ascent))
  const descent = Math.max(...paragraph.getLineMetrics().map(l => l.descent))
  const height = ascent + descent
  const width = right
  const metrics = { ascent, descent, left, right, width, height }
  paragraph.delete()
  fontMgr.delete()
  return metrics
}

function draw_text(text: string, x: number, y: number, fontInfo: { fontData: Uint8Array, fontSize: number }) {
  const metrics = measureText(text, fontInfo)
  console.log(metrics)
  context.fillStyle = 'red'
  context.fillRect(
    x - metrics.left,
    y - metrics.ascent,
    metrics.width,
    metrics.height,
  )

  context.fillStyle = 'black'
  context.fillText(
    text,
    x,
    y,
  )
}

const fontSize =  50
const canvas = createCanvas(500, 200)
const context = canvas.getContext('2d')
context.fillStyle = 'white'
context.fillRect(0, 0, canvas.width, canvas.height)
canvas.loadFont(fontData, {family: 'Tangerine'})
context.font = `${fontSize}px Tangerine`
// context.textBaseline = 'top'
draw_text("Hello go World", 50, 50, { fontSize, fontData })
await Deno.writeFile('canvas.png', canvas.toBuffer())

the output: canvas

andykais commented 3 years ago

fwiw I created an issue with skia https://bugs.chromium.org/p/skia/issues/detail?id=12586

DjDeveloperr commented 3 years ago

@andykais your first attempt at the implementation looks great. I think it would be good enough for first pass (better than current inaccurate implementation)

And yes, we do have access to Font in context (it's a minified property, which we can access through context.we), and even FontMgr (in canvas.Cf).

So I think you can PR this function (but slightly modified to accept FontMgr and Font instead of fontSize and fontData), and I can add it in lib.js as a follow up 🤔

andykais commented 2 years ago

whats the current CanvasKit version?

DjDeveloperr commented 2 years ago

It is 0.32.0

suchislife801 commented 2 years ago

I think I've figure out how to measure text correctly.


// Make sure to set the font size first!
 context2d.font = `16px sans-serif`;

 // The text to display
 const text: string = `Hello World`;

 // Measure the width of a single character
 const charWidth: number = Math.floor(context2d.measureText("X").width);

 // Get the length of the text string
 const textLength: number = text.length;

 // textWidth at selected fontSize
 const textWidth: number = charWidth * textLength;
andykais commented 2 years ago

@webdev3301 I believe there is still an issue with measuring custom loaded fonts. See this message above: https://github.com/DjDeveloperr/deno-canvas/issues/17#issuecomment-926166920