Code Monkey home page Code Monkey logo

canvas-hypertxt's Introduction

Canvas HyperTxt ๐Ÿš€๐Ÿ“โœ

A zero dependency featherweight library to layout text on a canvas.

Version npm bundle size Code Coverage License Made By Glide

Quickstart โšก

import { split } from "canvas-hypertxt";

function renderWrappedText(ctx: CanvasRenderingContext2D, value: string, width: number, x: number, y: number) {
    ctx.font = "12px sans-serif"; // ideally don't do this every time, it is really slow.
    ctx.textBaseline = "top"; // just makes positioning easier to predict, not essential
    const lines = split(ctx, value, "12px sans-serif", width);
    for (const line of lines) {
        ctx.fillText(line, x, y);
        y += 15;
    }
}

function renderWrappedTextCentered(ctx: CanvasRenderingContext2D, value: string, width: number, x: number, y: number) {
    // ideally don't do this every time, it is really slow.
    ctx.font = "12px sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "top";
    const lines = split(ctx, value, "12px sans-serif", width);
    for (const line of lines) {
        ctx.fillText(line, x + width / 2, y);
        y += 15;
    }
}

Who is this for?

This library is inspired by the excellent canvas-txt but focuses instead on being a part of the rendering pipeline instead of drawing the text for you. This offers greater flexibility for those who need it. Additionally canvas-hypertxt focuses on layout performance, allowing for much faster overall layout and rendering performance compared to the original library. Some sacrifices are made in the name of performance (justify). This library is internally integrated and used in glide-data-grid and now is available as a standalone.

Comparison vs canvas-txt

While canvas-hypertxt does not set out to be a drop-in replacement to canvas-txt, we can drag race them.

All tests were done with 5000 iterations. To ensure a fair comparison times include font rendering time.

canvas-txt canvas-hypertxt canvas-hypertxt w/ HyperWrapping
20 char (no wrapping) 0.05 sec 0.05 sec 0.05 sec
100 char 0.59 sec 0.11 sec 0.11 sec
300 char 2.63 sec 0.40 sec 0.26 sec
600 char 6.17 sec 0.81 sec 0.47 sec
1000 char 11.19 sec 1.43 sec 0.77 sec
1800 char (overflow) 22.47 sec 2.29 sec 1.19 sec

Benchmark code can be found here. You can run benchmarks on your machine here

canvas-multiline-text is not included in this chart because it fails to pass a basic correctness test. It can't handle wrapping correctly if there are no words to break at, nor does it handle newlines. Due to this it ends up making fewer draw calls and a significant amount of rendering happens off-canvas reducing drawing overhead further due to the correctness errors.

That said canvas-hypertxt tends to be around 20-30% faster than canvas-multiline-text without hyper wrapping, and about 1.2x faster with.

How is this so much faster?

Canvas-txt is an excellent library but takes a very inefficient approach to finding wrap points. It is clearly not written with performance in mind, but rather with features and bundle size. Overall it is a fantastic library if raw speed is not important to your use case.

HyperWrapping

One of the major items introduced by canvas-hypertxt is the concept of hyper wrapping. When enabled the font engine will train a weighting model to provide estimates for string sizes. Once the model is sufficiently trained it will perform string wrapping without calling ctx.measureText once. This leads to massive performance gains at the cost of accuracy. In practice with most fonts and text bodies, once trained the hyper wrap guesses will be within 1% of the actual measured size. A buffer is added to ensure the text wraps slightly too early instead of clipping.

The end result is text that is correctly wrapped the vast majority of the time, with a very small number of errors where the text wraps too early by a single word. The performance gains in the measure pass are over 100x, with hyper wrapped text basically having zero cost vs unwrapped text of the same size.

What am I missing using canvas-hypertxt?

You miss some features.

Managed rendering

canvas-txt will render your string for you, figure out line heights, etc. With canvas-hypertxt this is on you. It's not hard to do but it is a bit of extra lifting. You could easily wrap the canvas-hypertxt split function to do the same thing canvas-txt does. Examples provided in the quickstart.

Justify

I didn't feel like implementing it, patches welcome. This will 100% be slower than non-justified text due to the large amounts of string manipulation and extra measurement required. If you need justification canvas-txt can do it.

Debug mode

Because canvas-hypertxt doesn't actually render the text, it also can't render debug boxes for you.

Automatic text alignment

Text alignment with canvas-hypertxt is not as simple as setting a flag, though it's close. Set the ctx.textAlign appropriately and change the x value you pass to fillText to correspond to the left, center, or right edge of the bounding box.

Why are these all missing?

canvas-hypertxt is intended to be used as part of larger libraries which need wrapping text. In these cases text-alignment or actual text rendering is handled by existing functions which may provide additional functionality. By not rendering the text for the consumer, more flexibility is granted, however it comes at the cost of simplicity.

Usage

This library consists of two methods.

export function split(
    ctx: CanvasRenderingContext2D,
    value: string,
    fontStyle: string,
    width: number,
    hyperWrappingAllowed: boolean
): readonly string[];

split takes the following parameters

Name Usage
ctx A CanvasRenderingContext2D
value The string which needs to be wrapped
fontStyle A unique key which represents the font configuration currently applied to ctx
width The maximum width of any line
hyperWrappingAllowed Whether or not to allow hyper wrapping
export function clearCache(): void;

Clear all size caches the library has collected so far. Ideally do this when fonts have finished loading.

async function clearCacheOnLoad() {
    if (document?.fonts?.ready === undefined) return;
    await document.fonts.ready;
    clearCache();
}

void clearCacheOnLoad();

canvas-hypertxt's People

Contributors

abenrob avatar christiaanscheermeijer avatar jassmith avatar m4xi1m3 avatar matthiask avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

canvas-hypertxt's Issues

Weird cache behavior

What happend:
After a few runs split() function stops working.

With this little snippet written in TS we can reproduce the problem:

const testValue1 = `LAJDLKAJDLKAJSLDJALSJDLKASJDLKASJDLKAJSLDJALSKJDKLAJDLKAJDLKJASLDJLAKSJDLKASJDLKAJSLDJALSDJLAKSD

ALSKDJALSJDKLSJALKDJASLKDJLKAJDLKAJSDLKJASKDJALSKJDLASJDLKASJLDJASLDJALSKJD`;

export const debug = () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  if (!ctx) {
    return;
  }

  ctx.font = `16px Manrope`;
  const results = new Array(50).fill(null).map((_, index) => {
    return split(ctx, testValue1 + ``.padStart(index, '1'), `16px Manrope`, 240, false);
  });

  results.forEach((it) => console.log(it.length));
};

Expected behavior: split() function still splits text by given width. (At least 8 rows, since we're adding little extra to the given string each iteration)
Current behavior: on second run function starts to return text splitted only by "\n" characters

image

Promlem solves if we run clearCache() before each iteration.
Environment:
MacOS 14.1.1, Chrome 124.0.6367.208

Leading spaces are removed from all wrapped lines except the first

We are using this library to render text which the user can enter into a textarea to display as a "note" in a mapping/drawing app.

Using the text area, it's possible to add some rudimentary "formatting" by entering leading spaces, or adding lots of spaces between words.

These are lost when using this library, which loses the formatting. You can see the issue here where the text is laid out with canvas-hypertxt on the left and the textarea on the right:
image

The exact text is:

   bbc.com and.                                      another bbc.com

new line bbc.com

   more
new
lines

bbc.com

Cannot use textwrapping function

function renderWrappedText(ctx, value, width, x, y, fontSize, lineHeight) {
    ctx.font = "600 " + fontSize + "px sans-serif";

    let lines = split(ctx, value, fontSize + "px sans-serif", width, true);

    for (const line of lines) {
        ctx.fillText(line, x, y);
        y += lineHeight;
    }
}

I use this code to make my project, and if I don't setctx.font = "600 " + fontSize + "px sans-serif";the font will be very small. But that can make the wrapping function work.

Advanced mode: Blocking Constraints

One example where this library doesn't work well:
have a Paragraph with some inline spans that have different styling (e.g. bold, different fontSize etc.)
currently the library assumes styling is uniform across paragraph.

The way to solve this is to offer an API function that work per Span and not per Paragraph, but being able to pass on constraints. Such as for a given "line" how much of the available space is blocked already (could encode it as vectors, e.g. if line width is 100, 0-20 blocked, 60-80 blocked etc).

This would also allow Text to "wrap around" objects (e.g. image inside text), which is currently not possible with HTML / CSS.

The result of this advanced function call could be used for Canvas or SVG text rendering.

Essentially for a given Array of Spans with different Style and array of line blocking vector (which one could manually precompute), one wants returned an array of "TextFragments", that each have their own bounding box, X and Y etc.

Division by 0 when text has consecutive newlines

An easy way to reproduce is to add the following spec to the test suite:

const consecutiveNewlinesStr = `This is a quite long string 
that will need to wrap at least a 

couple times in order to 
fit on the screen. Who knows how many times?`;

...

test("consecutives newlines", () => {
    render(<canvas data-testid="canvas" />);
    ``;

    const canvas = screen.getByTestId("canvas") as HTMLCanvasElement;
    const ctx = canvas.getContext("2d", {
        alpha: false,
    });

    expect(ctx).not.toBeNull();

    if (ctx === null) {
        throw new Error("Error");
    }

    let spanned = splitMultilineText(ctx, consecutiveNewlinesStr, "12px bold", 400, false);
    expect(spanned).toEqual([
        "This is a quite long string",
        "that will need to wrap at least a",
        "",
        "couple times in order to fit on the screen. Who",
        "knows how many times?",
    ]);

    spanned = splitMultilineText(ctx, consecutiveNewlinesStr, "12px bold", 200, false);
    console.log('spanned: ', spanned);
    expect(spanned).toEqual([
        "This is a quite long",
        "string",
        "that will need to wrap at",
        "least a",
        "",
        "couple times in order to",
        "fit on the screen. Who",
        "knows how many times?",
    ]);
});

This will fail with:

  โ— multi-line-layout โ€บ consecutives newlines

    expect(received).toEqual(expected) // deep equality

    - Expected  - 7
    + Received  + 3

      Array [
    -   "This is a quite long",
    -   "string",
    -   "that will need to wrap at",
    -   "least a",
    +   "This is a quite long string",
    +   "that will need to wrap at least a",
        "",
    -   "couple times in order to",
    -   "fit on the screen. Who",
    -   "knows how many times?",
    +   "couple times in order to fit on the screen. Who knows how many times?",
      ]

By taking a closer look at the failure, the text didn't wrap at all!

I was able to track down this issue to fontMetrics.size being NaN caused by doing const avg = result.width / text.length; on line 96 of multi-line.ts as two consecutives newlines cause text to be an empty string.

Also, as far as I understand, this only happens the second time because on the first one fontMetrics is undefined so value.length is used as safeLineGuess.

I see two possible solutions, but I'm not sure which one would be prefered:

  1. Early return on measureText if Number.isNaN(avg) returning result.width

    const avg = result.width / text.length;
    
    if (Number.isNaN(avg)) {
        return result.width;
    }
  2. Not calling measureText if we have an empty string, something like:

    const text = line.slice(0, Math.max(0, safeLineGuess));
    let textWidth = 0;
    if (text) {
    	measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode);
    }

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.